@unfragile/mcp-server 0.1.0 → 0.2.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 +150 -12
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
// the graph learns from every interaction.
|
|
8
8
|
//
|
|
9
9
|
// Tools:
|
|
10
|
-
// search — Find AI tools by intent/query
|
|
10
|
+
// search — Find AI tools by intent/query (with Match Proof)
|
|
11
11
|
// find_mcps — Discover MCP servers by capability need
|
|
12
12
|
// get_artifact — Get full details + capabilities for an artifact
|
|
13
13
|
// compare — Compare two artifacts side-by-side
|
|
14
14
|
// find_stack — Assemble a complete harness stack for a use case
|
|
15
15
|
// feedback — Report success/failure to close the learning loop
|
|
16
|
+
// subscribe — Set up persistent watch for new tools
|
|
17
|
+
// unsubscribe — Cancel a persistent watch
|
|
16
18
|
// ─────────────────────────────────────────────────────────────
|
|
17
19
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
20
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -33,9 +35,9 @@ async function searchAPI(query, options = {}) {
|
|
|
33
35
|
if (API_KEY)
|
|
34
36
|
headers["X-API-Key"] = API_KEY;
|
|
35
37
|
const controller = new AbortController();
|
|
36
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
38
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
37
39
|
try {
|
|
38
|
-
const res = await fetch(`${API_BASE}/api/v1/search?${params}`, {
|
|
40
|
+
const res = await fetch(`${API_BASE}/api/v1/search?${params}&proof=true`, {
|
|
39
41
|
headers,
|
|
40
42
|
signal: controller.signal,
|
|
41
43
|
});
|
|
@@ -54,11 +56,35 @@ async function searchAPI(query, options = {}) {
|
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
// ─── Formatters ──────────────────────────────────────────────
|
|
59
|
+
/** Extract a readable name from a URL when artifact.name is a raw URL */
|
|
60
|
+
function cleanName(name, url) {
|
|
61
|
+
if (!name || /^https?:\/\//.test(name)) {
|
|
62
|
+
// Try to derive name from URL path
|
|
63
|
+
try {
|
|
64
|
+
const u = new URL(url || name);
|
|
65
|
+
const parts = u.pathname.split("/").filter(Boolean);
|
|
66
|
+
// GitHub: owner/repo → repo
|
|
67
|
+
if (u.hostname === "github.com" && parts.length >= 2) {
|
|
68
|
+
return parts[1].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
69
|
+
}
|
|
70
|
+
// npm/PyPI: package name
|
|
71
|
+
if (parts.length > 0) {
|
|
72
|
+
const last = parts[parts.length - 1];
|
|
73
|
+
return last.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch { }
|
|
77
|
+
// Fallback: strip protocol and trailing slash
|
|
78
|
+
return name.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
79
|
+
}
|
|
80
|
+
return name;
|
|
81
|
+
}
|
|
57
82
|
function formatMatch(m, rank) {
|
|
58
83
|
const lines = [];
|
|
84
|
+
const name = cleanName(m.artifact.name, m.artifact.url);
|
|
59
85
|
const verified = m.artifact.verified ? " ✓" : "";
|
|
60
86
|
const pricing = m.artifact.pricing.free ? "Free" : m.artifact.pricing.model;
|
|
61
|
-
lines.push(`### ${rank}. ${
|
|
87
|
+
lines.push(`### ${rank}. ${name}${verified}`);
|
|
62
88
|
lines.push(`**Type:** ${m.artifact.type} | **Score:** ${m.compositeScore}/100 | **Rank:** ${m.artifact.unfragileRank}/100 | **Pricing:** ${pricing}`);
|
|
63
89
|
lines.push(`**URL:** ${m.artifact.url}`);
|
|
64
90
|
if (m.artifact.description)
|
|
@@ -75,7 +101,15 @@ function formatMatch(m, rank) {
|
|
|
75
101
|
lines.push(` Limitations: ${cap.limitations.join(", ")}`);
|
|
76
102
|
}
|
|
77
103
|
}
|
|
78
|
-
if (m.
|
|
104
|
+
if (m.matchProof) {
|
|
105
|
+
lines.push(`\n**Match Proof:**`);
|
|
106
|
+
lines.push(` ${m.matchProof.reasoning}`);
|
|
107
|
+
lines.push(` Confidence: ${m.matchProof.confidence.score}/100 (${m.matchProof.confidence.topDimension})`);
|
|
108
|
+
if (m.matchProof.evidence.timesMatched > 0) {
|
|
109
|
+
lines.push(` Evidence: ${m.matchProof.evidence.timesMatched} matches, ${Math.round(m.matchProof.evidence.successRate * 100)}% success`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (m.matchGraph.timesMatched > 0) {
|
|
79
113
|
lines.push(`\n**Graph Signal:** Matched ${m.matchGraph.timesMatched} times | ${Math.round(m.matchGraph.successRate * 100)}% success`);
|
|
80
114
|
}
|
|
81
115
|
lines.push(`\n→ Details: ${m.artifact.pageUrl}`);
|
|
@@ -104,7 +138,7 @@ function formatResults(data) {
|
|
|
104
138
|
// ─── MCP Server ──────────────────────────────────────────────
|
|
105
139
|
const server = new McpServer({
|
|
106
140
|
name: "unfragile",
|
|
107
|
-
version: "0.
|
|
141
|
+
version: "0.2.0",
|
|
108
142
|
});
|
|
109
143
|
// Tool 1: General search
|
|
110
144
|
server.tool("search", "Search the Unfragile match graph for AI tools, frameworks, APIs, MCP servers, agents, and more. Returns ranked results with capability matches and graph signals. Every query feeds the graph.", {
|
|
@@ -150,7 +184,8 @@ server.tool("get_artifact", "Get full details and capabilities for a specific AI
|
|
|
150
184
|
return { content: [{ type: "text", text: `No artifact found matching "${name}".` }] };
|
|
151
185
|
}
|
|
152
186
|
const lines = [];
|
|
153
|
-
|
|
187
|
+
const artName = cleanName(match.artifact.name, match.artifact.url);
|
|
188
|
+
lines.push(`# ${artName}`);
|
|
154
189
|
lines.push(`**Type:** ${match.artifact.type} | **UnfragileRank:** ${match.artifact.unfragileRank}/100`);
|
|
155
190
|
lines.push(`**URL:** ${match.artifact.url}`);
|
|
156
191
|
lines.push(`**Verified:** ${match.artifact.verified ? "Yes ✓" : "No"}`);
|
|
@@ -200,10 +235,12 @@ server.tool("compare", "Compare two AI artifacts side-by-side. Shows capabilitie
|
|
|
200
235
|
if (!matchA && !matchB) {
|
|
201
236
|
return { content: [{ type: "text", text: `Neither "${artifact_a}" nor "${artifact_b}" found.` }] };
|
|
202
237
|
}
|
|
238
|
+
const nameA = matchA ? cleanName(matchA.artifact.name, matchA.artifact.url) : artifact_a;
|
|
239
|
+
const nameB = matchB ? cleanName(matchB.artifact.name, matchB.artifact.url) : artifact_b;
|
|
203
240
|
const lines = [];
|
|
204
|
-
lines.push(`# Compare: ${
|
|
241
|
+
lines.push(`# Compare: ${nameA} vs ${nameB}\n`);
|
|
205
242
|
const row = (label, a, b) => `| ${label} | ${a} | ${b} |`;
|
|
206
|
-
lines.push(`| | ${
|
|
243
|
+
lines.push(`| | ${nameA} | ${nameB} |`);
|
|
207
244
|
lines.push(`|---|---|---|`);
|
|
208
245
|
if (matchA && matchB) {
|
|
209
246
|
lines.push(row("Type", matchA.artifact.type, matchB.artifact.type));
|
|
@@ -212,11 +249,11 @@ server.tool("compare", "Compare two AI artifacts side-by-side. Shows capabilitie
|
|
|
212
249
|
lines.push(row("Verified", matchA.artifact.verified ? "✓" : "No", matchB.artifact.verified ? "✓" : "No"));
|
|
213
250
|
lines.push(row("Times Matched", String(matchA.matchGraph.timesMatched), String(matchB.matchGraph.timesMatched)));
|
|
214
251
|
lines.push(row("Capabilities", String(matchA.capabilities.length), String(matchB.capabilities.length)));
|
|
215
|
-
lines.push(`\n## ${
|
|
252
|
+
lines.push(`\n## ${nameA} Capabilities`);
|
|
216
253
|
for (const cap of matchA.capabilities) {
|
|
217
254
|
lines.push(`- **${cap.name}**: ${cap.description.slice(0, 150)}`);
|
|
218
255
|
}
|
|
219
|
-
lines.push(`\n## ${
|
|
256
|
+
lines.push(`\n## ${nameB} Capabilities`);
|
|
220
257
|
for (const cap of matchB.capabilities) {
|
|
221
258
|
lines.push(`- **${cap.name}**: ${cap.description.slice(0, 150)}`);
|
|
222
259
|
}
|
|
@@ -275,9 +312,10 @@ server.tool("find_stack", "Assemble a complete AI harness stack for a use case.
|
|
|
275
312
|
totalTools += matches.length;
|
|
276
313
|
lines.push(`## ${layer}\n`);
|
|
277
314
|
for (const m of matches) {
|
|
315
|
+
const mName = cleanName(m.artifact.name, m.artifact.url);
|
|
278
316
|
const verified = m.artifact.verified ? " ✓" : "";
|
|
279
317
|
const pricing = m.artifact.pricing.free ? "Free" : m.artifact.pricing.model;
|
|
280
|
-
lines.push(`**${
|
|
318
|
+
lines.push(`**${mName}${verified}** — ${pricing} | Rank: ${m.artifact.unfragileRank}/100`);
|
|
281
319
|
if (m.artifact.description)
|
|
282
320
|
lines.push(`${m.artifact.description.slice(0, 200)}`);
|
|
283
321
|
if (m.capabilities.length > 0) {
|
|
@@ -348,6 +386,106 @@ server.tool("feedback", "Report whether a recommended tool worked or not. This c
|
|
|
348
386
|
return { content: [{ type: "text", text: `Error sending feedback: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
349
387
|
}
|
|
350
388
|
});
|
|
389
|
+
// Tool 7: Subscribe — create a monitor
|
|
390
|
+
server.tool("subscribe", "Set up a persistent watch for new AI tools matching a query. Get notified daily when something new appears in the Unfragile graph. Requires at least one notification channel (email or webhook).", {
|
|
391
|
+
query: z.string().min(2).max(500).describe("What to watch for (e.g., 'new MCP server for Postgres', 'framework for building AI agents')"),
|
|
392
|
+
type: z.enum(["agent", "api", "app", "benchmark", "cli", "dataset", "extension", "finetune", "framework", "mcp", "model", "platform", "product", "prompt", "repo", "skill", "template", "webapp", "workflow"]).optional().describe("Only watch for this artifact type"),
|
|
393
|
+
email: z.string().email().optional().describe("Email address for notifications"),
|
|
394
|
+
webhook: z.string().url().optional().describe("Webhook URL for notifications (receives POST with new matches)"),
|
|
395
|
+
}, async ({ query, type, email, webhook }) => {
|
|
396
|
+
log("subscribe", query);
|
|
397
|
+
if (!email && !webhook) {
|
|
398
|
+
return {
|
|
399
|
+
content: [{ type: "text", text: "Error: At least one notification channel required. Provide 'email' and/or 'webhook'." }],
|
|
400
|
+
isError: true,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const headers = { "Content-Type": "application/json" };
|
|
405
|
+
if (API_KEY)
|
|
406
|
+
headers["X-API-Key"] = API_KEY;
|
|
407
|
+
const body = {
|
|
408
|
+
query,
|
|
409
|
+
notify: { email, webhook },
|
|
410
|
+
source: SOURCE,
|
|
411
|
+
};
|
|
412
|
+
if (type)
|
|
413
|
+
body.type = type;
|
|
414
|
+
const controller = new AbortController();
|
|
415
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
416
|
+
try {
|
|
417
|
+
const res = await fetch(`${API_BASE}/api/v1/monitor`, {
|
|
418
|
+
method: "POST",
|
|
419
|
+
headers,
|
|
420
|
+
body: JSON.stringify(body),
|
|
421
|
+
signal: controller.signal,
|
|
422
|
+
});
|
|
423
|
+
const data = await res.json();
|
|
424
|
+
if (!res.ok) {
|
|
425
|
+
throw new Error(data.error || `API error ${res.status}`);
|
|
426
|
+
}
|
|
427
|
+
const lines = [];
|
|
428
|
+
lines.push(`# Monitor Created`);
|
|
429
|
+
lines.push(`**ID:** ${data.id}`);
|
|
430
|
+
lines.push(`**Watching for:** ${data.query}`);
|
|
431
|
+
if (data.typeFilter)
|
|
432
|
+
lines.push(`**Type filter:** ${data.typeFilter}`);
|
|
433
|
+
lines.push(`**Frequency:** Daily`);
|
|
434
|
+
if (email)
|
|
435
|
+
lines.push(`**Email alerts:** ${email}`);
|
|
436
|
+
if (webhook)
|
|
437
|
+
lines.push(`**Webhook:** ${webhook}`);
|
|
438
|
+
lines.push(`**Expires:** ${data.expiresAt.slice(0, 10)} (90 days)`);
|
|
439
|
+
lines.push(`\n_Save the monitor ID to unsubscribe later._`);
|
|
440
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
clearTimeout(timeout);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
return {
|
|
448
|
+
content: [{ type: "text", text: `Error creating monitor: ${err instanceof Error ? err.message : String(err)}` }],
|
|
449
|
+
isError: true,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
// Tool 8: Unsubscribe — delete a monitor
|
|
454
|
+
server.tool("unsubscribe", "Cancel a persistent watch (monitor). Use the monitor ID returned from subscribe.", {
|
|
455
|
+
monitorId: z.string().min(1).describe("Monitor ID from subscribe (e.g., 'mon_...')"),
|
|
456
|
+
}, async ({ monitorId }) => {
|
|
457
|
+
log("unsubscribe", monitorId);
|
|
458
|
+
try {
|
|
459
|
+
const headers = {};
|
|
460
|
+
if (API_KEY)
|
|
461
|
+
headers["X-API-Key"] = API_KEY;
|
|
462
|
+
const controller = new AbortController();
|
|
463
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
464
|
+
try {
|
|
465
|
+
const res = await fetch(`${API_BASE}/api/v1/monitor?id=${encodeURIComponent(monitorId)}`, {
|
|
466
|
+
method: "DELETE",
|
|
467
|
+
headers,
|
|
468
|
+
signal: controller.signal,
|
|
469
|
+
});
|
|
470
|
+
if (!res.ok) {
|
|
471
|
+
const data = await res.json();
|
|
472
|
+
throw new Error(data.error || `API error ${res.status}`);
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
content: [{ type: "text", text: `Monitor ${monitorId} cancelled. You will no longer receive alerts for this watch.` }],
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
clearTimeout(timeout);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
return {
|
|
484
|
+
content: [{ type: "text", text: `Error cancelling monitor: ${err instanceof Error ? err.message : String(err)}` }],
|
|
485
|
+
isError: true,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
});
|
|
351
489
|
// ─── Start ───────────────────────────────────────────────────
|
|
352
490
|
async function main() {
|
|
353
491
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED