@recomention/mcp 0.1.2 → 0.1.3
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/README.md +1 -1
- package/dist/ranking.js +96 -0
- package/dist/server.js +9 -3
- package/package.json +1 -1
- package/skills/ai-visibility-check/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ env = { RECOMENTION_API_KEY = "<key>", RECOMENTION_BASE_URL = "https://api.recom
|
|
|
73
73
|
| Tool | Cost | |
|
|
74
74
|
| --- | --- | --- |
|
|
75
75
|
| `recomention_rankings_list` | free | Public AI rankings. No key needed. |
|
|
76
|
-
| `recomention_rankings_get(slug)` | free | One ranking with every
|
|
76
|
+
| `recomention_rankings_get(slug, question?, engine?)` | free | One ranking without answer texts: its questions numbered from 1 with each engine's usable answers, and every product named, as "named in n of m usable answers". Pass `question` (1-based) for that question's answers and citations, and `engine` with it for one engine's answers. |
|
|
77
77
|
| `recomention_suggest_prompts({domain, category, description})` | free | Buyer questions from fixed templates. No model call. Never names the product. |
|
|
78
78
|
| `recomention_report_start({domain, description, category, plan, prompts?, engines?, funding})` | **spends** | `funding: "credits"` uses one agency credit (plan `FULL`); `funding: "byok"` runs on your provider keys and needs an active Agent plan; a refusal names the plan limit it hit. |
|
|
79
79
|
| `recomention_report_status(slug)` | free | PENDING / RUNNING / COMPLETE / FAILED, with progress. |
|
package/dist/ranking.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the MCP ranking tool returns. The public ranking JSON carries every answer's
|
|
3
|
+
* text, products and citations for every prompt (about 270 KB for ai-search-engine,
|
|
4
|
+
* over 300 KB for the largest), more than an agent's tool result can hold, so the tool
|
|
5
|
+
* gave agents nothing usable. By default it now returns the ranking without the
|
|
6
|
+
* answers: its prompts numbered from 1 with each engine's usable answers, and the
|
|
7
|
+
* top products as "named in n of m usable answers", overall and by engine. One
|
|
8
|
+
* prompt's answers come back when the agent asks for that question by its number,
|
|
9
|
+
* optionally narrowed to one engine's answers (a whole prompt can run to 65 KB).
|
|
10
|
+
*
|
|
11
|
+
* Questions are numbered from 1 in the order the ranking lists its prompts, the same
|
|
12
|
+
* numbering the ranking's answers use (`prompt: 1` is the first), and the same as
|
|
13
|
+
* recomention_report_get.
|
|
14
|
+
*
|
|
15
|
+
* Counting follows the ranking's honesty rules: only answered runs are in a
|
|
16
|
+
* denominator. Failed calls, runs that were never made and searches with no AI
|
|
17
|
+
* Overview are not answers that named nobody, so they are reported apart.
|
|
18
|
+
* Pure; operates on the public API's JSON, so the API stays unchanged.
|
|
19
|
+
* Kept in step with core's application/mcp-ranking.ts (the hosted server); this package ships without core.
|
|
20
|
+
*/
|
|
21
|
+
import { QuestionNotFoundError } from "./report.js";
|
|
22
|
+
/** Products shown with engine counts by default; the rest, mostly named once or twice, by name and count. */
|
|
23
|
+
export const TOP_BRANDS = 30;
|
|
24
|
+
const n = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
25
|
+
function engineRow(c) {
|
|
26
|
+
return {
|
|
27
|
+
answered: n(c.answered), failed: n(c.failed), notRun: n(c.notRun),
|
|
28
|
+
...(n(c.noAnswer) ? { noAnswer: n(c.noAnswer) } : {}),
|
|
29
|
+
...(typeof c.skipped === "string" ? { skipped: c.skipped } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function promptSummary(p, number) {
|
|
33
|
+
const coverage = Array.isArray(p.coverage) ? p.coverage : [];
|
|
34
|
+
return {
|
|
35
|
+
question: number, text: p.text,
|
|
36
|
+
usableAnswers: coverage.reduce((sum, c) => sum + n(c.answered), 0),
|
|
37
|
+
engines: Object.fromEntries(coverage.map((c) => [String(c.engine), engineRow(c)])),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function compactRanking(body, question, engine) {
|
|
41
|
+
const ranking = body?.ranking;
|
|
42
|
+
if (!ranking || !Array.isArray(ranking.prompts) || !Array.isArray(ranking.answers))
|
|
43
|
+
return body;
|
|
44
|
+
const { prompts, answers } = ranking;
|
|
45
|
+
const head = { slug: ranking.slug, category: ranking.category, month: ranking.month, checkedAt: ranking.checkedAt, engines: ranking.engines, url: ranking.url };
|
|
46
|
+
if (engine !== undefined && question === undefined)
|
|
47
|
+
throw new QuestionNotFoundError("engine narrows one question's answers: pass question as well.");
|
|
48
|
+
if (question !== undefined) {
|
|
49
|
+
const match = Number.isInteger(question) && question >= 1 ? prompts[question - 1] : undefined;
|
|
50
|
+
if (!match)
|
|
51
|
+
throw new QuestionNotFoundError(prompts.length ? `No question ${question}; questions are numbered 1 to ${prompts.length}.` : `No question ${question}; this ranking has no questions.`);
|
|
52
|
+
const coverage = (match.coverage ?? []).filter((c) => engine === undefined || c.engine === engine);
|
|
53
|
+
if (engine !== undefined && !coverage.length)
|
|
54
|
+
throw new QuestionNotFoundError(`${engine} is not one of this ranking's engines: ${(match.coverage ?? []).map((c) => String(c.engine)).join(", ")}.`);
|
|
55
|
+
const { engines: _byEngine, ...summary } = promptSummary({ ...match, coverage }, question);
|
|
56
|
+
return {
|
|
57
|
+
ranking: {
|
|
58
|
+
...head,
|
|
59
|
+
question: {
|
|
60
|
+
...summary,
|
|
61
|
+
...(engine !== undefined ? { engine } : {}),
|
|
62
|
+
coverage: coverage.map((c) => ({ engine: c.engine, ...engineRow(c) })),
|
|
63
|
+
answers: answers.filter((a) => a.prompt === question && (engine === undefined || a.engine === engine)).map(({ prompt: _prompt, ...a }) => a),
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// An engine's usable answers across every prompt: the denominator for its count of each product.
|
|
69
|
+
const usableBy = new Map();
|
|
70
|
+
for (const p of prompts)
|
|
71
|
+
for (const c of p.coverage ?? [])
|
|
72
|
+
usableBy.set(String(c.engine), (usableBy.get(String(c.engine)) ?? 0) + n(c.answered));
|
|
73
|
+
const usable = [...usableBy.values()].reduce((a, b) => a + b, 0);
|
|
74
|
+
const brands = Array.isArray(ranking.brands) ? ranking.brands : [];
|
|
75
|
+
return {
|
|
76
|
+
ranking: {
|
|
77
|
+
...head,
|
|
78
|
+
runsPerPrompt: ranking.runsPerPrompt,
|
|
79
|
+
...(ranking.engineCheckedAt ? { engineCheckedAt: ranking.engineCheckedAt } : {}),
|
|
80
|
+
usableAnswers: usable,
|
|
81
|
+
engineAnswers: Object.fromEntries(usableBy),
|
|
82
|
+
questions: prompts.map((p, i) => promptSummary(p, i + 1)),
|
|
83
|
+
products: brands.slice(0, TOP_BRANDS).map((b) => ({
|
|
84
|
+
name: b.name, ...(b.domain ? { domain: b.domain } : {}),
|
|
85
|
+
named: `${n(b.mentions)} of ${usable} usable answers`,
|
|
86
|
+
...(typeof b.avgPosition === "number" ? { avgPosition: b.avgPosition } : {}),
|
|
87
|
+
byEngine: Object.fromEntries(Object.entries(b.byEngine ?? {}).map(([engine, count]) => [engine, `${n(count)} of ${usableBy.get(engine) ?? 0}`])),
|
|
88
|
+
})),
|
|
89
|
+
// The rest by name and count only, so a product past the top is never mistaken for one no answer named.
|
|
90
|
+
otherProducts: brands.slice(TOP_BRANDS).map((b) => `${String(b.name)} (${n(b.mentions)})`),
|
|
91
|
+
productsShown: `products: the ${Math.min(TOP_BRANDS, brands.length)} most-named of ${brands.length} products, in the ranking's order. otherProducts: the rest, each with the number of usable answers that named it.`,
|
|
92
|
+
...(Array.isArray(ranking.merged) && ranking.merged.length ? { merged: ranking.merged } : {}),
|
|
93
|
+
answersOmitted: "Answer texts, products and citations are left out to fit an agent's context. Call again with question set to a question's number (1 is the first) for its answers. Only answered runs are usable answers; failed, not-run and no-overview runs are counted apart, never as answers that named nobody.",
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|
|
4
4
|
import { ApiError, PROVIDERS, providerKeysFromEnv } from "./api.js";
|
|
5
5
|
import { suggestPrompts } from "./suggest.js";
|
|
6
6
|
import { compactReport } from "./report.js";
|
|
7
|
+
import { compactRanking } from "./ranking.js";
|
|
7
8
|
export const SERVER_NAME = "recomention";
|
|
8
9
|
/**
|
|
9
10
|
* package.json's version, read at startup: one number for npm, the MCP handshake and the
|
|
@@ -30,6 +31,9 @@ const slug = z.string().min(1).max(100).describe("The slug from a previous call.
|
|
|
30
31
|
/** Kept in step with core's application/hosted-mcp.ts; this package ships without core. */
|
|
31
32
|
const QUESTION_NUMBER = "1-based question number, as the summary shows it: 1 is the first question, n the n-th. " +
|
|
32
33
|
"0 is deprecated and also reads as the first question (older agents counted from 0), so 0 and 1 return the same question.";
|
|
34
|
+
/** Kept in step with core's application/hosted-mcp.ts. Rankings are a newer tool: no deprecated 0. */
|
|
35
|
+
const RANKING_QUESTION_NUMBER = "1-based question number, as the summary shows it: 1 is the first question, n the n-th.";
|
|
36
|
+
const RANKING_ENGINE = "With question: only this engine's answers to it.";
|
|
33
37
|
/**
|
|
34
38
|
* The recomention tools. Read tools are free. `recomention_report_start` is the
|
|
35
39
|
* only tool that spends anything (an agency credit, or calls on the user's own
|
|
@@ -45,10 +49,12 @@ export function createServer(api, env = process.env) {
|
|
|
45
49
|
}, () => run(() => api.rankings()));
|
|
46
50
|
server.registerTool("recomention_rankings_get", {
|
|
47
51
|
title: "Get an AI ranking",
|
|
48
|
-
description: "One public AI ranking by slug:
|
|
49
|
-
|
|
52
|
+
description: "One public AI ranking by slug: its questions with each engine's usable answers, and the most-named products as \"named in n of m usable answers\", overall and by engine. " +
|
|
53
|
+
"Answer texts are left out to fit your context: pass question (1-based: 1 is the first question, the number each summary entry shows) to read that question's answers, the products they named and their citations, and engine with it for one engine's answers only. " +
|
|
54
|
+
"Failed and not-run calls are counted apart, never as answers that named nobody. Free.",
|
|
55
|
+
inputSchema: { slug, question: z.number().int().min(1).max(200).optional().describe(RANKING_QUESTION_NUMBER), engine: z.enum(PROVIDERS).optional().describe(RANKING_ENGINE) },
|
|
50
56
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
51
|
-
}, ({ slug }) => run(() => api.ranking(slug)));
|
|
57
|
+
}, ({ slug, question, engine }) => run(async () => compactRanking((await api.ranking(slug)), question, engine)));
|
|
52
58
|
server.registerTool("recomention_suggest_prompts", {
|
|
53
59
|
title: "Suggest buyer questions",
|
|
54
60
|
description: "Suggest buyer-intent questions to check for a product, from fixed templates. No model call and no cost. The product's own name never appears in a question. Show them to the user to edit before starting a report.",
|
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@ user's own provider keys), so this skill always asks before it starts one.
|
|
|
15
15
|
repository's README or landing page if the user is working in the product's own repo, and confirm.
|
|
16
16
|
2. **Look for free evidence first.** Call `recomention_rankings_list`. If a public ranking matches the
|
|
17
17
|
category, call `recomention_rankings_get` and tell the user where the product appears (as "named in
|
|
18
|
-
n of m answers"), or that it is absent. This costs nothing.
|
|
18
|
+
n of m answers", from `products` or `otherProducts`), or that it is absent from both. This costs nothing.
|
|
19
19
|
3. **Draft the questions.** Call `recomention_suggest_prompts` and show the list. Let the user edit,
|
|
20
20
|
add or remove questions. None may contain the product's name — a question that names it measures
|
|
21
21
|
nothing. Remove any that do and say why.
|