Sign In

tracetify-mcp

Package Overview
Dependencies
Maintainers
1
Versions
9
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

tracetify-mcp - npm Package Compare versions

Comparing version
0.2.0
to
0.3.0
+268
src/tools.generated.json
{
"generatedFrom": "web/lib/mcp/tool-manifest.ts",
"tools": [
{
"name": "search_reports",
"title": "Search growth reports",
"description": "Use this FIRST whenever the user asks how a product, competitor or domain grew, got traffic, or found its first users — a report may already exist and reading it is free. Returns matching report slugs for read_report. Searches by domain or name fragment.",
"params": [
{
"key": "query",
"type": "string",
"required": true,
"description": "Domain or fragment, e.g. \"weshop\" or \"weshop.ai\""
}
],
"rest": {
"method": "GET",
"path": "/api/mcp/v1/reports/search?q={query}"
},
"billing": "free"
},
{
"name": "read_report",
"title": "Read a growth report",
"description": "Read one growth report by slug (from search_reports or a finished trace). Free. Contains the origin story, dated evidence and SEO footprint — cite it instead of guessing how a product grew. If timeline fields show as locked, unlock_report can open them (that one costs credits).",
"params": [
{
"key": "slug",
"type": "string",
"required": true,
"description": "Report slug"
}
],
"rest": {
"method": "GET",
"path": "/api/mcp/v1/reports/{slug}"
},
"billing": "free"
},
{
"name": "start_trace",
"title": "Trace how a product grew",
"description": "Run this when search_reports finds nothing (or the user wants fresh data) for \"how did X grow?\". Rebuilds the growth story from 12 public sources in 60-90s. Costs credits from the Tracetify balance; returns an existing cached report free instead when one is fresh. Poll with get_trace.",
"params": [
{
"key": "url",
"type": "string",
"required": true,
"description": "Domain to trace, e.g. weshop.ai"
},
{
"key": "refresh",
"type": "boolean",
"required": false,
"description": "Force a fresh run even if a cached report exists"
}
],
"rest": {
"method": "POST",
"path": "/api/mcp/v1/trace"
},
"billing": "credits"
},
{
"name": "get_trace",
"title": "Check a running trace",
"description": "Poll a trace started with start_trace. Free. When status is \"done\", read the result with read_report. Poll every 10-15s, not in a tight loop.",
"params": [
{
"key": "job_id",
"type": "string",
"required": true,
"description": "Job id returned by start_trace"
}
],
"rest": {
"method": "GET",
"path": "/api/mcp/v1/trace/{job_id}"
},
"billing": "free"
},
{
"name": "unlock_report",
"title": "Unlock full timeline & evidence",
"description": "Permanently unlock a report's full timeline, evidence and SEO detail for this account. Costs credits — quote the exact price to the user first (it is in the report's timelineLocked.cost field from read_report) and call this ONLY after they explicitly agree to spend. Idempotent: unlocking an already-unlocked report never charges twice. The verdict stays on the website — you are the analyst here.",
"params": [
{
"key": "slug",
"type": "string",
"required": true,
"description": "Report slug"
}
],
"rest": {
"method": "POST",
"path": "/api/mcp/v1/reports/{slug}/unlock"
},
"billing": "credits"
},
{
"name": "gsc_overview",
"title": "Search Console overview",
"description": "Use this before touching SEO on the user's own site: clicks/impressions trend, device split and period comparison from THEIR connected Google Search Console — numbers no other tool has. Free. Requires GSC connected at tracetify.com/dashboard/gsc (this tool tells you if it is not).",
"params": [
{
"key": "range_days",
"type": "number",
"required": false,
"description": "28 (default) or 90"
}
],
"rest": {
"method": "GET",
"path": "/api/mcp/v1/gsc/overview?range_days={range_days}"
},
"billing": "free"
},
{
"name": "gsc_queries",
"title": "Search Console queries",
"description": "The user's real ranking keywords with position, clicks and CTR — use when deciding what to write or which page to improve, e.g. finding queries at position 5-20 that are one push from page one. Free; own data from their connected Search Console. After you edit a page, this is how you verify it moved.",
"params": [
{
"key": "range_days",
"type": "number",
"required": false,
"description": "28 (default) or 90"
}
],
"rest": {
"method": "GET",
"path": "/api/mcp/v1/gsc/queries?range_days={range_days}"
},
"billing": "free"
},
{
"name": "gsc_pages",
"title": "Search Console pages",
"description": "The user's pages ranked by search performance, including high-impression low-CTR pages whose titles/descriptions are underselling — fix those files right in this editor. Free; own data from their connected Search Console.",
"params": [
{
"key": "range_days",
"type": "number",
"required": false,
"description": "28 (default) or 90"
}
],
"rest": {
"method": "GET",
"path": "/api/mcp/v1/gsc/pages?range_days={range_days}"
},
"billing": "free"
},
{
"name": "site_audit_start",
"title": "Start a site audit",
"description": "Run right after deploying: crawls the site and reports broken links, missing titles/descriptions, redirect chains, thin content and schema gaps — each finding names the page so you can fix it here. Costs credits (price returned before any charge on the confirmation field). Async: poll with site_audit_get.",
"params": [
{
"key": "url",
"type": "string",
"required": true,
"description": "Site to audit, e.g. https://example.com"
},
{
"key": "confirm",
"type": "boolean",
"required": false,
"description": "Set true only after the user agrees to the quoted credit cost"
},
{
"key": "request_key",
"type": "string",
"required": false,
"description": "Request key returned by the first confirmation response; must be sent back with confirm=true so retries cannot charge twice"
}
],
"rest": {
"method": "POST",
"path": "/api/mcp/v1/site-audit"
},
"billing": "credits"
},
{
"name": "site_audit_get",
"title": "Read a site audit",
"description": "Poll an audit started with site_audit_start (free to read). When finished, returns the issue list grouped by severity with affected URLs — work through it top-down and re-run after fixes.",
"params": [
{
"key": "audit_id",
"type": "string",
"required": true,
"description": "Audit id from site_audit_start"
}
],
"rest": {
"method": "GET",
"path": "/api/mcp/v1/site-audit/{audit_id}"
},
"billing": "free"
},
{
"name": "research_domain_overview",
"title": "Domain SEO overview",
"description": "Estimated organic traffic, keyword count and top keywords for ANY domain — use to size up a competitor the user mentions. Costs credits; cached results are free, and repeated queries within a week hit the cache. For the user's own site prefer gsc_* (free, real data).",
"params": [
{
"key": "domain",
"type": "string",
"required": true,
"description": "Domain, e.g. competitor.com"
}
],
"rest": {
"method": "POST",
"path": "/api/mcp/v1/research/domain-overview"
},
"billing": "credits"
},
{
"name": "research_backlinks",
"title": "Backlink profile",
"description": "Who links to a domain: referring domains, authority and anchor texts. Use when planning link building or judging how defensible a competitor's ranking is. Costs credits; cached results are free. Pair with backlink_directories to find places the user can actually get listed.",
"params": [
{
"key": "domain",
"type": "string",
"required": true,
"description": "Domain to inspect"
}
],
"rest": {
"method": "POST",
"path": "/api/mcp/v1/research/backlinks"
},
"billing": "credits"
},
{
"name": "research_brand_lookup",
"title": "AI search visibility",
"description": "How AI assistants (ChatGPT, Perplexity-class) cite a brand: platforms, mention counts and the entities it gets associated with. Use when the user asks \"does AI recommend us/them?\". Costs credits; cached results are free.",
"params": [
{
"key": "brand",
"type": "string",
"required": true,
"description": "Brand or domain"
}
],
"rest": {
"method": "POST",
"path": "/api/mcp/v1/research/brand-lookup"
},
"billing": "credits"
},
{
"name": "backlink_directories",
"title": "Verified directory list",
"description": "A hand-verified list of directories and launch platforms that actually give links — checked one by one, dead and nofollow-only entries removed. Use when the user wants backlinks or launch exposure for a new product; filter client-side by language/type/pricing. Costs credits once per day per account — repeat calls the same day are free, so refine freely.",
"params": [],
"rest": {
"method": "GET",
"path": "/api/mcp/v1/backlink-directories"
},
"billing": "credits"
}
]
}
+3
-2
{
"name": "tracetify-mcp",
"mcpName": "io.github.tracetify/tracetify-mcp",
"version": "0.2.0",
"version": "0.3.0",
"description": "MCP server for Tracetify — trace how any product actually grew, from inside Claude Code or Cursor.",

@@ -25,3 +25,4 @@ "keywords": [

"bin",
"src",
"src/server.mjs",
"src/tools.generated.json",
"README.md",

@@ -28,0 +29,0 @@ "LICENSE"

@@ -31,2 +31,12 @@ # tracetify-mcp

Cursor takes the same JSON in `~/.cursor/mcp.json`. Codex uses TOML in
`~/.codex/config.toml` — note the snake_case table name:
```toml
[mcp_servers.tracetify]
command = "npx"
args = ["-y", "tracetify-mcp"]
env = { TRACETIFY_API_KEY = "ttfy_..." }
```
## Tools

@@ -33,0 +43,0 @@

+134
-77
/**
* 薄客户端:只做 MCP ↔ Tracetify REST 的协议转换,不含任何业务逻辑。
* 编排、防坑规则、分析层全在服务端(MCP spec 架构决定 4)。
* createServer 把 fetch 做成注入点——测试不用起网络。
* 工具定义来自 web manifest 的生成物,避免 npm 与 HTTP 两条通道漂移。
*/
import { readFileSync } from 'node:fs';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

@@ -11,11 +11,101 @@ import { z } from 'zod';

const DEFAULT_BASE = 'https://tracetify.com';
const manifest = JSON.parse(
readFileSync(new URL('./tools.generated.json', import.meta.url), 'utf8')
);
const PLACEHOLDER = /\{(\w+)\}/g;
function hasValue(value) {
return value !== undefined && value !== null;
}
function replacePlaceholders(template, args) {
return template.replace(PLACEHOLDER, (_, key) => encodeURIComponent(String(args[key])));
}
// 这段算法与 web/lib/mcp/rest-call.ts 保持相同,包内不跨目录 import web 代码。
function buildRestRequest(def, args) {
for (const param of def.params) {
if (param.required && !hasValue(args[param.key])) {
throw new Error(`Missing required parameter: ${param.key}`);
}
}
const [pathnameTemplate, queryTemplate] = def.rest.path.split('?', 2);
for (const match of pathnameTemplate.matchAll(PLACEHOLDER)) {
if (!hasValue(args[match[1]])) {
throw new Error(`Missing path parameter: ${match[1]}`);
}
}
const pathname = replacePlaceholders(pathnameTemplate, args);
const query = queryTemplate
?.split('&')
.filter((part) => {
const keys = Array.from(part.matchAll(PLACEHOLDER), (match) => match[1]);
return keys.every((key) => hasValue(args[key]));
})
.map((part) => replacePlaceholders(part, args))
.filter(Boolean)
.join('&');
const path = query ? `${pathname}?${query}` : pathname;
if (def.rest.method === 'GET') {
return { method: 'GET', path, body: null };
}
const templatedKeys = new Set(
Array.from(def.rest.path.matchAll(PLACEHOLDER), (match) => match[1])
);
const bodyEntries = def.params
.filter((param) => !templatedKeys.has(param.key) && hasValue(args[param.key]))
.map((param) => [param.key, args[param.key]]);
return {
method: 'POST',
path,
body: bodyEntries.length > 0 ? JSON.stringify(Object.fromEntries(bodyEntries)) : null,
};
}
function zodParam(param) {
let schema;
if (param.type === 'string') schema = z.string();
else if (param.type === 'number') schema = z.number();
else if (param.type === 'boolean') schema = z.boolean();
else throw new Error(`Unsupported MCP parameter type: ${param.type}`);
if (!param.required) schema = schema.optional();
return schema.describe(param.description);
}
export function createServer({ apiKey, baseUrl = DEFAULT_BASE, fetchImpl = fetch } = {}) {
const server = new McpServer({ name: 'tracetify', version: '0.2.0' });
const server = new McpServer({ name: 'tracetify', version: '0.3.0' });
const text = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
async function call(path, init = {}) {
// 没配 key 时 server 照常启动、工具照常列出——目录站的健康检查和
// 用户的 tools/list 都不该因为缺环境变量而看到一个死进程;引导
// 放在真正调用的那一刻,出现在宿主的对话里,比 stderr 里一行
// 没人看的报错有用得多
function mcpError(payload) {
return {
isError: true,
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
};
}
function restError(status, responseBody) {
// 由 handler 直接返回 MCP 错误,避免 SDK 把抛出的 Error 压缩成一行 message,
// 从而丢掉 code、connectUrl、balance 等宿主需要采取下一步行动的字段。
const structured = responseBody
&& typeof responseBody === 'object'
&& !Array.isArray(responseBody)
? responseBody
: responseBody === null
? {}
: { details: responseBody };
const fallback = `Tracetify API error (HTTP ${status})`;
const error = typeof structured.error === 'string' && structured.error
? structured.error
: fallback;
return mcpError({ ...structured, error, httpStatus: status });
}
async function call({ method, path, body }) {
// 未配 key 时仍允许启动和列工具;把配置指引放在真正调用时,宿主才能展示给用户。
if (!apiKey) {

@@ -28,79 +118,46 @@ throw new Error(

}
const res = await fetchImpl(`${baseUrl}${path}`, {
...init,
const init = {
method,
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
...init.headers,
},
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || `Tracetify API error (HTTP ${res.status})`);
return body;
};
if (body !== null) init.body = body;
const res = await fetchImpl(`${baseUrl}${path}`, init);
let responseBody = null;
let parsedJson = true;
try {
responseBody = await res.json();
} catch {
// 响应正文可能包含上游内部信息;这里只记录解析状态,不保留正文或异常栈。
parsedJson = false;
}
if (!res.ok) {
return restError(res.status, responseBody);
}
if (!parsedJson) {
return mcpError({
error: 'Tracetify API returned invalid JSON',
code: 'INVALID_RESPONSE',
httpStatus: res.status,
});
}
return text(responseBody);
}
const text = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
for (const def of manifest.tools) {
const inputSchema = Object.fromEntries(
def.params.map((param) => [param.key, zodParam(param)])
);
server.registerTool(
def.name,
{ title: def.title, description: def.description, inputSchema },
async (args) => call(buildRestRequest(def, args))
);
}
server.registerTool(
'search_reports',
{
description:
'Search existing Tracetify growth reports by domain. Reading existing reports is free.',
inputSchema: { query: z.string().describe('Domain or fragment, e.g. "weshop" or "weshop.ai"') },
},
async ({ query }) => text(await call(`/api/mcp/v1/reports/search?q=${encodeURIComponent(query)}`))
);
server.registerTool(
'read_report',
{
description:
'Read one Tracetify growth report by slug (from search_reports or a completed trace). Free.',
inputSchema: { slug: z.string() },
},
async ({ slug }) => text(await call(`/api/mcp/v1/reports/${encodeURIComponent(slug)}`))
);
server.registerTool(
'start_trace',
{
description:
'Trace how a product actually grew: 12 sources, takes 60-90s, costs credits from your Tracetify balance. Returns a cached report slug for free when a fresh one already exists. Poll progress with get_trace.',
inputSchema: {
url: z.string().describe('Domain to trace, e.g. weshop.ai'),
refresh: z.boolean().optional().describe('Force a fresh run even if a cached report exists'),
},
},
async ({ url, refresh }) =>
text(await call('/api/mcp/v1/trace', {
method: 'POST',
body: JSON.stringify({ url, refresh: refresh === true }),
}))
);
server.registerTool(
'unlock_report',
{
description:
"Permanently unlock a report's full timeline, evidence and SEO detail for this account. "
+ "Costs credits — quote the exact price to the user first (it is in the report's "
+ "timelineLocked.cost field from read_report) and call this ONLY after they explicitly "
+ "agree to spend. Idempotent: unlocking an already-unlocked report never charges twice. "
+ "Returns the full report. The verdict stays on the website — you are the analyst here.",
inputSchema: { slug: z.string() },
},
async ({ slug }) =>
text(await call(`/api/mcp/v1/reports/${encodeURIComponent(slug)}/unlock`, { method: 'POST' }))
);
server.registerTool(
'get_trace',
{
description: 'Check a running trace. When status is "done", read the report with read_report.',
inputSchema: { job_id: z.string() },
},
async ({ job_id }) => text(await call(`/api/mcp/v1/trace/${encodeURIComponent(job_id)}`))
);
return server;
}
import { describe, expect, it, vi } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createServer } from './server.mjs';
async function connected(fetchImpl) {
const server = createServer({ apiKey: 'ttfy_test', baseUrl: 'https://api.test', fetchImpl });
const client = new Client({ name: 'test', version: '0.0.0' });
const [a, b] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(a), client.connect(b)]);
return client;
}
describe('tracetify-mcp server', () => {
it('exposes the four phase-1 tools', async () => {
const client = await connected(vi.fn());
const { tools } = await client.listTools();
expect(tools.map((t) => t.name).sort()).toEqual(
['get_trace', 'read_report', 'search_reports', 'start_trace', 'unlock_report']
);
});
it('search_reports hits the REST API with the key and returns its JSON', async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ reports: [{ slug: 'weshop-ai' }] }),
});
const client = await connected(fetchImpl);
const res = await client.callTool({ name: 'search_reports', arguments: { query: 'weshop' } });
expect(fetchImpl).toHaveBeenCalledWith(
'https://api.test/api/mcp/v1/reports/search?q=weshop',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer ttfy_test' }),
})
);
expect(res.content[0].text).toContain('weshop-ai');
});
it('start_trace posts url + refresh', async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: 'started', jobId: 'j1' }),
});
const client = await connected(fetchImpl);
await client.callTool({ name: 'start_trace', arguments: { url: 'weshop.ai', refresh: true } });
expect(fetchImpl).toHaveBeenCalledWith(
'https://api.test/api/mcp/v1/trace',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ url: 'weshop.ai', refresh: true }) })
);
});
it('surfaces API errors as tool errors instead of fake success', async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: false,
status: 402,
json: async () => ({ error: 'A trace costs 10 credits' }),
});
const client = await connected(fetchImpl);
const res = await client.callTool({ name: 'start_trace', arguments: { url: 'weshop.ai' } });
expect(res.isError).toBe(true);
expect(res.content[0].text).toContain('A trace costs 10 credits');
});
it('starts without an API key: tools list, calls return setup guidance', async () => {
const fetchImpl = vi.fn();
const server = createServer({ baseUrl: 'https://api.test', fetchImpl });
const client = new Client({ name: 'test', version: '0.0.0' });
const [a, b] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(a), client.connect(b)]);
const { tools } = await client.listTools();
expect(tools).toHaveLength(5);
const res = await client.callTool({ name: 'search_reports', arguments: { query: 'x' } });
expect(res.isError).toBe(true);
expect(res.content[0].text).toContain('tracetify.com/dashboard');
expect(fetchImpl).not.toHaveBeenCalled();
});
it('unlock_report posts to the unlock endpoint', async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ charged: true, balance: 219, report: { host: 'weshop.ai' } }),
});
const client = await connected(fetchImpl);
const res = await client.callTool({ name: 'unlock_report', arguments: { slug: 'weshop-ai' } });
expect(fetchImpl).toHaveBeenCalledWith(
'https://api.test/api/mcp/v1/reports/weshop-ai/unlock',
expect.objectContaining({ method: 'POST' })
);
expect(res.content[0].text).toContain('"charged": true');
});
});