tracetify-mcp
Advanced tools
| { | ||
| "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" |
+10
-0
@@ -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'); | ||
| }); | ||
| }); |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
20324
62.49%431
123.32%58
20.83%4
33.33%2
100%