@recomention/mcp 0.1.1 → 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 CHANGED
@@ -73,11 +73,11 @@ 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 answer it counted. |
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. |
80
- | `recomention_report_get(slug)` | free | The full report JSON. |
80
+ | `recomention_report_get(slug, question?)` | free | The report without answer texts, with per-question counts numbered from 1. Pass `question` (1-based: 1 is the first) for that question's answers and citations. `0` is deprecated and also means the first. |
81
81
  | `recomention_distribution_kit(slug)` | free | Markdown files built from a finished report: product, competitors, AI visibility, where to get mentioned, channel plan. |
82
82
  | `recomention_reports_list` | free | Your reports, newest first. |
83
83
 
@@ -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/report.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const usable = (a) => !a.failed && !a.noAnswer && (a.mentioned === true || a.mentioned === false);
2
- export function questionSummary(q) {
2
+ export function questionSummary(q, number) {
3
3
  const answers = Array.isArray(q.answers) ? q.answers : [];
4
4
  const byEngine = new Map();
5
5
  for (const a of answers) {
@@ -16,31 +16,46 @@ export function questionSummary(q) {
16
16
  const named = [...byEngine.values()].reduce((n, e) => n + e.named, 0);
17
17
  const of = [...byEngine.values()].reduce((n, e) => n + e.usable, 0);
18
18
  return {
19
- order: q.order, text: q.text, intent: q.intent, stage: q.stage,
19
+ question: number, text: q.text, intent: q.intent, stage: q.stage,
20
20
  named: `${named} of ${of} usable answers`,
21
21
  engines: Object.fromEntries([...byEngine].map(([engine, e]) => [engine, { named: `${e.named} of ${e.usable}`, notUsable: e.notUsable }])),
22
22
  };
23
23
  }
24
+ /**
25
+ * The agent asked for a question the report does not have. Thrown, not returned: the
26
+ * API answered 200, so a returned { error } would reach the agent as a successful result.
27
+ * Both servers turn it into a tool error (isError: true) carrying this message.
28
+ */
29
+ export class QuestionNotFoundError extends Error {
30
+ name = "QuestionNotFoundError";
31
+ }
32
+ const ZERO_IS_DEPRECATED = "question 0 is deprecated: questions are numbered from 1, and 0 reads as 1, the first question.";
24
33
  export function compactReport(body, question) {
25
34
  const report = body?.report;
26
35
  if (!report || !Array.isArray(report.questions))
27
36
  return body;
28
37
  const questions = report.questions;
29
38
  if (question !== undefined) {
30
- // By the question's own order (real reports number from 0), never by list position.
31
- const match = questions.find((q) => q.order === question);
32
- if (!match) {
33
- const orders = questions.map((q) => Number(q.order)).filter(Number.isFinite);
34
- return { error: orders.length ? `No question with order ${question}; orders run ${Math.min(...orders)} to ${Math.max(...orders)}.` : `No question with order ${question}.` };
35
- }
36
- return { report: { slug: report.slug, brand: report.brand, domain: report.domain, simulated: report.simulated, enginesSkipped: report.enginesSkipped, question: match } };
39
+ // 1-based: question n is the n-th in the report's order. The deprecated 0 also means the first.
40
+ const index = question === 0 ? 0 : question - 1;
41
+ const match = Number.isInteger(index) && index >= 0 ? questions[index] : undefined;
42
+ if (!match)
43
+ throw new QuestionNotFoundError(questions.length ? `No question ${question}; questions are numbered 1 to ${questions.length}.` : `No question ${question}; this report has no questions.`);
44
+ const { order: _storedOrder, ...rest } = match;
45
+ return {
46
+ report: {
47
+ slug: report.slug, brand: report.brand, domain: report.domain, simulated: report.simulated, enginesSkipped: report.enginesSkipped,
48
+ question: { question: index + 1, ...rest },
49
+ ...(question === 0 ? { numbering: ZERO_IS_DEPRECATED } : {}),
50
+ },
51
+ };
37
52
  }
38
53
  return {
39
54
  ...body,
40
55
  report: {
41
56
  ...report,
42
- questions: questions.map(questionSummary),
43
- answersOmitted: `Answer texts, brands and citations are left out to fit an agent's context. Call again with question set to a question's order (from 0) for its answers. Engines in enginesSkipped did not run.`,
57
+ questions: questions.map((q, i) => questionSummary(q, i + 1)),
58
+ answersOmitted: `Answer texts, brands 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. Engines in enginesSkipped did not run.`,
44
59
  },
45
60
  };
46
61
  }
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
@@ -27,6 +28,12 @@ async function run(fn) {
27
28
  }
28
29
  }
29
30
  const slug = z.string().min(1).max(100).describe("The slug from a previous call.");
31
+ /** Kept in step with core's application/hosted-mcp.ts; this package ships without core. */
32
+ const QUESTION_NUMBER = "1-based question number, as the summary shows it: 1 is the first question, n the n-th. " +
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.";
30
37
  /**
31
38
  * The recomention tools. Read tools are free. `recomention_report_start` is the
32
39
  * only tool that spends anything (an agency credit, or calls on the user's own
@@ -42,10 +49,12 @@ export function createServer(api, env = process.env) {
42
49
  }, () => run(() => api.rankings()));
43
50
  server.registerTool("recomention_rankings_get", {
44
51
  title: "Get an AI ranking",
45
- description: "One public AI ranking by slug: every product named, how many answers named it (as n of m), and the verbatim answers. Free.",
46
- inputSchema: { slug },
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) },
47
56
  annotations: { readOnlyHint: true, openWorldHint: true },
48
- }, ({ slug }) => run(() => api.ranking(slug)));
57
+ }, ({ slug, question, engine }) => run(async () => compactRanking((await api.ranking(slug)), question, engine)));
49
58
  server.registerTool("recomention_suggest_prompts", {
50
59
  title: "Suggest buyer questions",
51
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.",
@@ -93,9 +102,9 @@ export function createServer(api, env = process.env) {
93
102
  server.registerTool("recomention_report_get", {
94
103
  title: "Get a report",
95
104
  description: "A report as JSON: the overall score, each engine's runs mentioned of runs total, competitors named instead, cited sources, the fix list and, per question, how many usable answers named the product. " +
96
- "Answer texts are left out to fit your context: pass question (its order, from 0) to read that question's answers and citations. " +
105
+ "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 and citations. " +
97
106
  "Engines in enginesSkipped did not run; never describe them as not mentioning the product. simulated: true means test data. Free.",
98
- inputSchema: { slug, question: z.number().int().min(0).max(200).optional().describe("A question's order number, to read its answers.") },
107
+ inputSchema: { slug, question: z.number().int().min(0).max(200).optional().describe(QUESTION_NUMBER) },
99
108
  annotations: { readOnlyHint: true, openWorldHint: true },
100
109
  }, ({ slug, question }) => run(async () => compactReport((await api.report(slug)), question)));
101
110
  server.registerTool("recomention_distribution_kit", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recomention/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "MCP server for recomention: ask whether AI engines recommend a product, from Claude Code, Cursor or Codex.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@modelcontextprotocol/sdk": "^1.32.1",
45
- "zod": "^4.4.3"
45
+ "zod": "^4.6.7"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^26.2.0",
@@ -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.
@@ -18,8 +18,9 @@ Reads a finished report with the `recomention_*` MCP tools. Costs nothing: it on
18
18
  suggests it (point at the evidence: a competitor's share of answers, a cited source the product is
19
19
  missing from, a question where it never appears), and the effort.
20
20
  4. **Ground it in the answers.** For the two or three questions where the product is absent most
21
- often, quote one short excerpt of an engine answer and the products it named instead. Use
22
- `questions[].answers[]`; skip answers with `failed: true`.
21
+ often, quote one short excerpt of an engine answer and the products it named instead. Call
22
+ `recomention_report_get` again with `question` set to that question's number (1 is the first) for
23
+ its answers; skip answers with `failed: true`.
23
24
  5. **Separate what the user controls from what they don't.** Their own site and docs: concrete edits
24
25
  they can make now (if you are in the product's repository, offer to draft them). Third-party pages
25
26
  (`sources` the engines cite): who to contact and what an honest inclusion request looks like.