@index365/mcp
Advanced tools
+2
-2
| { | ||
| "name": "@index365/mcp", | ||
| "version": "0.1.4", | ||
| "description": "index365 MCP server (stdio) for AI-Readiness and Marketing Signal scans plus read-only access to runs and findings. No Website Security scan tool.", | ||
| "version": "1.0.0", | ||
| "description": "index365 MCP server (stdio). Start index365 scans and read prioritized findings your coding agent can use. Website Security is not available over MCP yet.", | ||
| "mcpName": "io.github.index365usa/index365", | ||
@@ -6,0 +6,0 @@ "type": "module", |
+37
-6
| # @index365/mcp | ||
| The [index365](https://index365.co) MCP server. It gives Claude Code, Codex, and Cursor read access to your audit runs and findings, plus the ability to start audits, over the public `/api/v1`. | ||
| The [index365](https://index365.co) MCP server. It lets Claude Code, Codex, and Cursor start index365 scans and read prioritized findings your coding agent can use, over the public `/api/v1`. | ||
| This is a thin wrapper: no audit logic, no database access, no filesystem access, no ambient workspace permissions. It authenticates with an `i365_` API key from the host environment and acts with the scopes that key carries — every key is full scope, so it can start audits and manage projects out of the box. Keys are organization-scoped and revocable instantly. | ||
| This is a thin wrapper: no scan logic, no database access, no filesystem access, no ambient workspace permissions. It authenticates with an `i365_` API key from the host environment and acts with the scopes that key carries. Keys are organization-scoped and revocable instantly. | ||
| index365 runs two audits: **AI-Readiness** (how well AI agents and AI search can read a site) and **Marketing Signal** (find, trust, act, measure, improve). Each run produces a score plus findings with stable IDs, evidence, and machine-readable remediation. | ||
| index365 runs two scans today: **AI-Readiness** (how well AI agents and AI search can read a site) and **Marketing Signal** (find, trust, act, measure, improve). Each scan produces a score plus findings with stable IDs, evidence, and machine-readable remediation. Website Security is not available over MCP yet. | ||
@@ -35,8 +35,39 @@ ## Install | ||
| Read: `list_projects`, `get_run_status`, `list_findings`, `get_finding`, `get_report_context`, `get_marketing_signal_report`, `list_integrations`, `list_connected_signals`. Run: `start_audit`, `run_marketing_signal_audit`. Projects: `create_project`, `archive_project`, and `restore_project`. | ||
| The canonical loop: `index365_list_projects` -> `index365_start_scan` -> `index365_get_report` -> `index365_get_finding` per fix -> re-scan to confirm. | ||
| `archive_project` reversibly removes a project from active lists while preserving its history and keys. `restore_project` reactivates it. The local stdio server retains `delete_project` only as a deprecated compatibility alias for `archive_project`; the hosted server omits that alias and exposes the truthful archive and restore names. `list_projects` accepts `status=active|paused|archived|all` and excludes archived projects when status is omitted. | ||
| - **Scan:** `index365_start_scan` starts a paid scan (`product: ai_readiness | marketing_signal`, 10 credits) and **waits**, returning the final status with the score and severity counts. Always pass `idempotencyKey`; if the call is cut off client-side, recover the in-flight run with `index365_get_scan_status` instead of starting again. | ||
| - **Read:** `index365_get_scan_status`, `index365_get_report` (pass `runId`, or `projectId` for the latest completed report, optionally with `product`), `index365_list_findings`, `index365_get_finding` (includes a copy-pasteable `fixPrompt`). | ||
| - **Projects:** `index365_list_projects`, `index365_create_project`, `index365_archive_project` (reversible, requires the exact `confirmDomain`), `index365_restore_project`. | ||
| - **Signals:** `index365_list_integrations`, `index365_list_connected_signals`. | ||
| Prompts: `triage_findings`, `prepare_pr_plan`, `marketing_fix_plan`, `summarize_run_for_slack`. | ||
| Prompts: `triage_findings`, `prepare_pr_plan`, `marketing_fix_plan`, `summarize_run_for_slack`, `fix_finding`. | ||
| ## MIGRATION (0.x -> 1.0) | ||
| 1.0 is a breaking rename with a grace period: the tool set was consolidated (14 -> 11) and every tool adopted the `index365_` vendor prefix. The old names no longer appear in `tools/list`, but they stay **callable for at least 90 days**: a call-time alias answers with the new tool's real result plus a one-line rename note. Aliases for the old start tools now wait for the scan like `index365_start_scan` does. Update configurations and saved prompts to the new names before the grace period ends: | ||
| | Old tool (0.x) | New tool (1.0) | | ||
| | --- | --- | | ||
| | `start_audit` | `index365_start_scan` (product defaults to `ai_readiness`; now waits and returns the final result) | | ||
| | `run_marketing_signal_audit` | `index365_start_scan` with `product: "marketing_signal"` | | ||
| | `get_run_status` | `index365_get_scan_status` | | ||
| | `get_report_context` | `index365_get_report` with `runId` | | ||
| | `get_marketing_signal_report` | `index365_get_report` with `projectId` + `product: "marketing_signal"` | | ||
| | `list_findings` | `index365_list_findings` | | ||
| | `get_finding` | `index365_get_finding` (adds `fixPrompt`) | | ||
| | `list_projects` | `index365_list_projects` | | ||
| | `create_project` | `index365_create_project` | | ||
| | `archive_project` | `index365_archive_project` | | ||
| | `restore_project` | `index365_restore_project` | | ||
| | `delete_project` | removed; use `index365_archive_project` (reversible) | | ||
| | `list_integrations` | `index365_list_integrations` | | ||
| | `list_connected_signals` | `index365_list_connected_signals` | | ||
| Behavior changes to note: | ||
| - `index365_start_scan` blocks until the scan is terminal (stdio waits up to 600s; the hosted server up to 240s). On cap expiry it returns a non-error still-running payload naming `index365_get_scan_status`. | ||
| - `index365_get_scan_status` adds `pollAfterSeconds` and, while running, a rough `etaSeconds`. | ||
| - `product: "website_security"` is accepted by the schema but returns a clear not-available-yet error without spending credits. | ||
| - The hosted server (`https://index365.co/api/mcp`) and the stdio server now expose the identical 11-tool registry. | ||
| ## Docs | ||
@@ -43,0 +74,0 @@ |
+4
-3
@@ -46,8 +46,8 @@ /** | ||
| export async function apiGet(settings, apiPath, query = {}, fetchImpl = fetch) { | ||
| return apiRequest(settings, "GET", apiPath, { query, fetchImpl }); | ||
| export async function apiGet(settings, apiPath, query = {}, fetchImpl = fetch, signal = undefined) { | ||
| return apiRequest(settings, "GET", apiPath, { query, fetchImpl, signal }); | ||
| } | ||
| export async function apiRequest(settings, method, apiPath, options = {}) { | ||
| const { query, body, idempotencyKey, fetchImpl = fetch } = options; | ||
| const { query, body, idempotencyKey, fetchImpl = fetch, signal } = options; | ||
| if (!settings.apiKey) { | ||
@@ -85,2 +85,3 @@ throw new ApiError( | ||
| body: body !== undefined ? JSON.stringify(body) : undefined, | ||
| ...(signal ? { signal } : {}), | ||
| }); | ||
@@ -87,0 +88,0 @@ } catch (err) { |
+29
-19
@@ -10,18 +10,15 @@ /** Type declarations for the tool handler logic (handlers.mjs). */ | ||
| /** The exact set of index365 MCP tool names. */ | ||
| /** The exact set of index365 MCP tool names (MCP 1.0, all vendor-prefixed). */ | ||
| export type ToolName = | ||
| | "list_projects" | ||
| | "start_audit" | ||
| | "get_run_status" | ||
| | "list_findings" | ||
| | "get_finding" | ||
| | "get_report_context" | ||
| | "run_marketing_signal_audit" | ||
| | "get_marketing_signal_report" | ||
| | "list_integrations" | ||
| | "list_connected_signals" | ||
| | "create_project" | ||
| | "archive_project" | ||
| | "restore_project" | ||
| | "delete_project"; | ||
| | "index365_list_projects" | ||
| | "index365_create_project" | ||
| | "index365_archive_project" | ||
| | "index365_restore_project" | ||
| | "index365_start_scan" | ||
| | "index365_get_scan_status" | ||
| | "index365_get_report" | ||
| | "index365_list_findings" | ||
| | "index365_get_finding" | ||
| | "index365_list_integrations" | ||
| | "index365_list_connected_signals"; | ||
@@ -31,3 +28,3 @@ /** | ||
| * a typo or a missing handler is a compile error, not a runtime undefined. Keys | ||
| * are concrete, so `handlers.start_audit(...)` stays definite under | ||
| * are concrete, so `handlers.index365_start_scan(...)` stays definite under | ||
| * noUncheckedIndexedAccess. | ||
@@ -37,2 +34,14 @@ */ | ||
| /** Options for buildHandlers. */ | ||
| export type BuildHandlersOptions = { | ||
| /** | ||
| * Max seconds index365_start_scan blocks while polling the run before | ||
| * returning the non-error still-running payload. stdio passes 600; the | ||
| * hosted route passes 240 (inside its Vercel maxDuration window). | ||
| */ | ||
| scanWaitCapSeconds?: number; | ||
| /** Poll cadence for the blocking wait in ms (default 5000; tests pass 0). */ | ||
| pollIntervalMs?: number; | ||
| }; | ||
| /** Build the tool handler map bound to the given API settings. */ | ||
@@ -42,5 +51,6 @@ export function buildHandlers( | ||
| fetchImpl?: typeof fetch, | ||
| options?: BuildHandlersOptions, | ||
| ): Index365Handlers; | ||
| /** Prompt definitions keyed by name. */ | ||
| /** Prompt definitions keyed by name. argsSchema maps arg name -> description. */ | ||
| export const PROMPTS: Record< | ||
@@ -50,4 +60,4 @@ string, | ||
| description: string; | ||
| argsSchema: { runId: string }; | ||
| build: (args: { runId: string }) => Array<{ | ||
| argsSchema: Record<string, string>; | ||
| build: (args: Record<string, string>) => Array<{ | ||
| role: string; | ||
@@ -54,0 +64,0 @@ content: { type: string; text: string }; |
+399
-109
@@ -8,8 +8,18 @@ import { ApiError, apiGet, apiRequest } from "./api.mjs"; | ||
| * | ||
| * MCP 1.0 handler map: exactly the 11 `index365_`-prefixed tools. The pre-1.0 | ||
| * names (start_audit, run_marketing_signal_audit, get_run_status, | ||
| * get_report_context, get_marketing_signal_report, delete_project and every | ||
| * unprefixed name) are NOT in this map or in tools/list, but they stay | ||
| * CALLABLE for a >=90-day grace period through the call-time alias shim in | ||
| * register.mjs (validated against the successor tool's schema, answered with | ||
| * the real result plus a rename note). Migration map lives in README.md. | ||
| * | ||
| * Output policy (Claude Code + ChatGPT apps guidance): | ||
| * - Lists are paginated server-side; `limit` is clamped to MAX_LIMIT here | ||
| * as well so a single tool call can never dump an unbounded payload. | ||
| * - Detail is fetch-by-id (get_finding / get_report_context), not embedded | ||
| * in list results. | ||
| * - Detail is fetch-by-id (index365_get_finding / index365_get_report), not | ||
| * embedded in list results. | ||
| * - JSON text output: maximally compatible across MCP hosts. | ||
| * - Every response carries a short `note` naming the next tool call; raw | ||
| * API payloads are otherwise passed through unchanged. | ||
| */ | ||
@@ -19,2 +29,21 @@ | ||
| /** Mirrors isTerminalRunStatus in apps/site/lib/api/run-projection.ts. */ | ||
| const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "failed_auto_credit", "refunded"]); | ||
| /** Product value -> /api/v1/runs scanMode (the existing mapping). */ | ||
| const SCAN_MODES = { | ||
| ai_readiness: "paid_ai_readiness", | ||
| marketing_signal: "paid_marketing_signal", | ||
| }; | ||
| const WEBSITE_SECURITY_UNAVAILABLE = | ||
| "Website Security scans are not available over MCP yet. They arrive when the Security report projection ships. AI-Readiness and Marketing Signal are available now."; | ||
| const REPORT_NEXT_NOTE = | ||
| "Next: index365_list_findings with the runId for the prioritized list, then index365_get_finding for one fix."; | ||
| /** index365_start_scan blocks this long by default (stdio). The hosted route passes 240. */ | ||
| const DEFAULT_SCAN_WAIT_CAP_SECONDS = 600; | ||
| const DEFAULT_POLL_INTERVAL_MS = 5000; | ||
| function ok(value) { | ||
@@ -24,8 +53,48 @@ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] }; | ||
| function failText(text) { | ||
| return { content: [{ type: "text", text }], isError: true }; | ||
| } | ||
| /** | ||
| * Error texts are next-step instructions: they name the recovery tool, the | ||
| * billing URL, or the retry policy instead of only restating the failure. | ||
| */ | ||
| function apiErrorGuidance(err) { | ||
| if (err.code === "no_key") return null; // the ApiError message already carries setup steps | ||
| if ( | ||
| err.status === 402 || | ||
| err.code === "insufficient_credits" || | ||
| err.code === "no_active_subscription" | ||
| ) { | ||
| return "The organization cannot spend credits right now. Ask the user to top up or upgrade at https://index365.co/dashboard/billing and do not retry until they have."; | ||
| } | ||
| if (err.status === 401) { | ||
| return "The API key was rejected. Ask the user to check INDEX365_API_KEY against the dashboard API Keys page, then retry."; | ||
| } | ||
| if (err.status === 403) { | ||
| return "The API key lacks the required scope. Ask the user to issue a key with that scope on the dashboard API Keys page."; | ||
| } | ||
| if (err.status === 404) { | ||
| return "Check the id. Project ids come from index365_list_projects; run ids from index365_start_scan or index365_get_scan_status; finding ids from index365_list_findings."; | ||
| } | ||
| if (err.status === 429) { | ||
| return "Rate limited. Wait for the Retry-After window, then repeat the same call once."; | ||
| } | ||
| if (err.code === "network") { | ||
| return "The index365 API was unreachable. Check connectivity and INDEX365_API_URL, then retry once."; | ||
| } | ||
| if (err.status >= 500) { | ||
| return "Transient server error. Retry the same call once after a short wait."; | ||
| } | ||
| return null; | ||
| } | ||
| function fail(err) { | ||
| const message = | ||
| err instanceof ApiError | ||
| ? `index365 API error (${err.code}): ${err.message}` | ||
| : `Unexpected error: ${err?.message ?? err}`; | ||
| return { content: [{ type: "text", text: message }], isError: true }; | ||
| if (!(err instanceof ApiError)) { | ||
| return failText(`Unexpected error: ${err?.message ?? err}`); | ||
| } | ||
| const guidance = apiErrorGuidance(err); | ||
| return failText( | ||
| `index365 API error (${err.code}): ${err.message}${guidance ? ` ${guidance}` : ""}`, | ||
| ); | ||
| } | ||
@@ -38,17 +107,101 @@ | ||
| export function buildHandlers(settings, fetchImpl = fetch) { | ||
| const archiveProject = async (args) => { | ||
| try { | ||
| const data = await apiRequest(settings, "DELETE", `/api/v1/projects/${args.projectId}`, { | ||
| query: { confirm: args.confirmDomain }, | ||
| fetchImpl, | ||
| }); | ||
| return ok(data); | ||
| } catch (err) { | ||
| return fail(err); | ||
| } | ||
| function withNote(data, note) { | ||
| if (data && typeof data === "object" && !Array.isArray(data)) return { ...data, note }; | ||
| return { data, note }; | ||
| } | ||
| function sleep(ms) { | ||
| return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve(); | ||
| } | ||
| function elapsedSecondsSince(startedAtMs) { | ||
| return Math.max(0, Math.round((Date.now() - startedAtMs) / 1000)); | ||
| } | ||
| /** Compact terminal payload for a blocking index365_start_scan: the report header, not the full report. */ | ||
| function terminalScanResult(envelope, elapsedSeconds) { | ||
| const base = { | ||
| runId: envelope.runId, | ||
| ...(envelope.product ? { product: envelope.product } : {}), | ||
| status: envelope.status, | ||
| score: envelope.score ?? null, | ||
| severityCounts: envelope.severityCounts ?? null, | ||
| ...(envelope.humanUrl ? { humanUrl: envelope.humanUrl } : {}), | ||
| elapsedSeconds, | ||
| }; | ||
| if (envelope.status === "completed") { | ||
| return { | ||
| ...base, | ||
| note: "Scan complete. Call index365_get_report with this runId for the full report context.", | ||
| }; | ||
| } | ||
| return { | ||
| ...base, | ||
| ...(envelope.error ? { error: envelope.error } : {}), | ||
| note: `The scan ended with status ${envelope.status}. Check the error field, fix the cause, then start a new scan with index365_start_scan.`, | ||
| }; | ||
| } | ||
| /** | ||
| * Copy-pasteable fix instruction composed from the finding detail, so an agent | ||
| * never re-derives what the API already specifies. | ||
| */ | ||
| function composeFixPrompt(finding, args) { | ||
| if (!finding || typeof finding !== "object") return null; | ||
| const lines = [ | ||
| "Fix this index365 finding in this project.", | ||
| "", | ||
| `Finding: ${finding.title ?? args.findingId}`, | ||
| `Id: ${finding.findingId ?? args.findingId} (run ${args.runId})`, | ||
| ]; | ||
| if (finding.severity) { | ||
| lines.push( | ||
| `Severity: ${finding.severity}${finding.category ? `, category: ${finding.category}` : ""}`, | ||
| ); | ||
| } | ||
| if (finding.stage) lines.push(`Funnel stage: ${finding.stage}`); | ||
| if (Array.isArray(finding.affectedUrls) && finding.affectedUrls.length > 0) { | ||
| lines.push("Affected URLs:"); | ||
| for (const url of finding.affectedUrls) lines.push(`- ${url}`); | ||
| } | ||
| if (finding.remediation) { | ||
| lines.push("", `Remediation: ${finding.remediation}`); | ||
| } | ||
| if (Array.isArray(finding.evidence) && finding.evidence.length > 0) { | ||
| lines.push("", `Evidence: ${JSON.stringify(finding.evidence)}`); | ||
| } | ||
| if (Array.isArray(finding.agentActions) && finding.agentActions.length > 0) { | ||
| lines.push("", `Agent actions: ${JSON.stringify(finding.agentActions)}`); | ||
| } | ||
| lines.push( | ||
| "", | ||
| "Apply the smallest safe change that resolves the finding, then verify it locally.", | ||
| ); | ||
| return lines.join("\n"); | ||
| } | ||
| /** | ||
| * Build the 11-tool handler map. | ||
| * | ||
| * @param settings { apiKey, apiUrl, client? } from resolveApiSettings / settingsFromBearer. | ||
| * @param fetchImpl fetch implementation (tests inject a mock). | ||
| * @param options.scanWaitCapSeconds Max seconds index365_start_scan blocks while polling | ||
| * before returning the non-error still-running payload. | ||
| * stdio passes 600; the hosted route passes 240. | ||
| * @param options.pollIntervalMs Poll cadence for the blocking wait (default 5000). | ||
| */ | ||
| export function buildHandlers(settings, fetchImpl = fetch, options = {}) { | ||
| const scanWaitCapSeconds = options.scanWaitCapSeconds ?? DEFAULT_SCAN_WAIT_CAP_SECONDS; | ||
| const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; | ||
| const getRunEnvelope = (runId, signal) => | ||
| apiGet(settings, `/api/v1/runs/${runId}`, {}, fetchImpl, signal); | ||
| const getRunReport = async (runId) => { | ||
| const data = await apiGet(settings, `/api/v1/runs/${runId}/report`, {}, fetchImpl); | ||
| return ok(withNote(data, REPORT_NEXT_NOTE)); | ||
| }; | ||
| return { | ||
| async list_projects(args) { | ||
| async index365_list_projects(args) { | ||
| try { | ||
@@ -61,3 +214,8 @@ const data = await apiGet( | ||
| ); | ||
| return ok(data); | ||
| return ok( | ||
| withNote( | ||
| data, | ||
| "Next: index365_start_scan with a projectId, or index365_create_project if the domain is not listed.", | ||
| ), | ||
| ); | ||
| } catch (err) { | ||
@@ -68,17 +226,14 @@ return fail(err); | ||
| async start_audit(args) { | ||
| async index365_create_project(args) { | ||
| try { | ||
| const data = await apiRequest(settings, "POST", "/api/v1/runs", { | ||
| body: { | ||
| projectId: args.projectId, | ||
| ...(args.url !== undefined ? { url: args.url } : {}), | ||
| }, | ||
| idempotencyKey: args.idempotencyKey, | ||
| const data = await apiRequest(settings, "POST", "/api/v1/projects", { | ||
| body: { domain: args.domain, name: args.name }, | ||
| fetchImpl, | ||
| }); | ||
| return ok({ | ||
| runId: data.runId, | ||
| status: data.status, | ||
| note: "Audit queued. Poll get_run_status until status is terminal (typically 2-5 minutes), then read get_report_context.", | ||
| }); | ||
| return ok( | ||
| withNote( | ||
| data, | ||
| "Project ready. Next: index365_start_scan with this projectId. A scan costs 10 credits.", | ||
| ), | ||
| ); | ||
| } catch (err) { | ||
@@ -89,6 +244,9 @@ return fail(err); | ||
| async get_run_status(args) { | ||
| async index365_archive_project(args) { | ||
| try { | ||
| const data = await apiGet(settings, `/api/v1/runs/${args.runId}`, {}, fetchImpl); | ||
| return ok(data); | ||
| const data = await apiRequest(settings, "DELETE", `/api/v1/projects/${args.projectId}`, { | ||
| query: { confirm: args.confirmDomain }, | ||
| fetchImpl, | ||
| }); | ||
| return ok(withNote(data, "Project archived. Undo anytime with index365_restore_project.")); | ||
| } catch (err) { | ||
@@ -99,17 +257,11 @@ return fail(err); | ||
| async list_findings(args) { | ||
| async index365_restore_project(args) { | ||
| try { | ||
| const data = await apiGet( | ||
| settings, | ||
| `/api/v1/runs/${args.runId}/findings`, | ||
| { | ||
| severity: args.severity, | ||
| category: args.category, | ||
| stage: args.stage, | ||
| limit: clampLimit(args.limit) ?? 20, | ||
| cursor: args.cursor, | ||
| }, | ||
| const data = await apiRequest(settings, "PATCH", `/api/v1/projects/${args.projectId}`, { | ||
| body: { status: "active" }, | ||
| fetchImpl, | ||
| }); | ||
| return ok( | ||
| withNote(data, "Project restored. Next: index365_start_scan for fresh findings."), | ||
| ); | ||
| return ok(data); | ||
| } catch (err) { | ||
@@ -120,20 +272,94 @@ return fail(err); | ||
| async get_finding(args) { | ||
| async index365_start_scan(args) { | ||
| const product = args.product ?? "ai_readiness"; | ||
| if (product === "website_security") return failText(WEBSITE_SECURITY_UNAVAILABLE); | ||
| const scanMode = SCAN_MODES[product]; | ||
| if (!scanMode) { | ||
| return failText( | ||
| `Unknown product '${product}'. Pass ai_readiness or marketing_signal, or omit product for AI-Readiness.`, | ||
| ); | ||
| } | ||
| let started; | ||
| try { | ||
| const data = await apiGet( | ||
| settings, | ||
| `/api/v1/runs/${args.runId}/findings/${args.findingId}`, | ||
| {}, | ||
| started = await apiRequest(settings, "POST", "/api/v1/runs", { | ||
| body: { | ||
| projectId: args.projectId, | ||
| scanMode, | ||
| ...(args.url !== undefined ? { url: args.url } : {}), | ||
| }, | ||
| idempotencyKey: args.idempotencyKey, | ||
| fetchImpl, | ||
| ); | ||
| return ok(data); | ||
| }); | ||
| } catch (err) { | ||
| return fail(err); | ||
| } | ||
| // The firecrawl_crawl pattern: block here, polling until the run is | ||
| // terminal, so ONE tool call returns the final result. On cap expiry | ||
| // the payload is non-error and routes recovery to index365_get_scan_status. | ||
| const startedAtMs = Date.now(); | ||
| const capMs = scanWaitCapSeconds * 1000; | ||
| let latest = started; | ||
| let consecutivePollErrors = 0; | ||
| while (Date.now() - startedAtMs < capMs) { | ||
| await sleep(pollIntervalMs); | ||
| let envelope; | ||
| try { | ||
| // Bound each poll so a hung status request cannot outlive the wait | ||
| // cap and swallow the recovery payload. 30s per poll, never past | ||
| // the remaining cap budget. | ||
| const remainingMs = Math.max(1000, capMs - (Date.now() - startedAtMs)); | ||
| envelope = await getRunEnvelope( | ||
| started.runId, | ||
| AbortSignal.timeout(Math.min(30_000, remainingMs)), | ||
| ); | ||
| consecutivePollErrors = 0; | ||
| } catch (err) { | ||
| consecutivePollErrors += 1; | ||
| if (consecutivePollErrors >= 3) { | ||
| const detail = err instanceof ApiError ? err.message : (err?.message ?? String(err)); | ||
| return ok({ | ||
| runId: started.runId, | ||
| status: latest?.status ?? "pending", | ||
| elapsedSeconds: elapsedSecondsSince(startedAtMs), | ||
| note: `Status checks are failing (${detail}). The scan may still be running. Call index365_get_scan_status with this runId; do not start a new scan.`, | ||
| }); | ||
| } | ||
| continue; | ||
| } | ||
| latest = envelope; | ||
| if (TERMINAL_RUN_STATUSES.has(envelope.status)) { | ||
| return ok(terminalScanResult(envelope, elapsedSecondsSince(startedAtMs))); | ||
| } | ||
| } | ||
| return ok({ | ||
| runId: started.runId, | ||
| status: latest?.status ?? "pending", | ||
| ...(typeof latest?.progressPct === "number" ? { progressPct: latest.progressPct } : {}), | ||
| ...(latest?.humanUrl ? { humanUrl: latest.humanUrl } : {}), | ||
| elapsedSeconds: elapsedSecondsSince(startedAtMs), | ||
| note: "Still running. Call index365_get_scan_status with this runId; do not start a new scan.", | ||
| }); | ||
| }, | ||
| async get_report_context(args) { | ||
| async index365_get_scan_status(args) { | ||
| try { | ||
| const data = await apiGet(settings, `/api/v1/runs/${args.runId}/report`, {}, fetchImpl); | ||
| return ok(data); | ||
| const data = await getRunEnvelope(args.runId); | ||
| const enriched = { ...data, pollAfterSeconds: 10 }; | ||
| if (data.status === "running" && data.startedAt) { | ||
| const elapsed = (Date.now() - Date.parse(data.startedAt)) / 1000; | ||
| if (Number.isFinite(elapsed)) { | ||
| enriched.etaSeconds = Math.max(0, Math.round(180 - elapsed)); | ||
| } | ||
| } | ||
| if (data.status === "completed") { | ||
| enriched.note = "Scan complete. Next: index365_get_report with this runId."; | ||
| } else if (TERMINAL_RUN_STATUSES.has(data.status)) { | ||
| enriched.note = `The scan ended with status ${data.status}. Check the error field, fix the cause, then start a new scan with index365_start_scan.`; | ||
| } else { | ||
| enriched.note = | ||
| "Scan in progress. Poll index365_get_scan_status again after pollAfterSeconds; scans typically take 2-5 minutes."; | ||
| } | ||
| return ok(enriched); | ||
| } catch (err) { | ||
@@ -144,18 +370,51 @@ return fail(err); | ||
| async run_marketing_signal_audit(args) { | ||
| async index365_get_report(args) { | ||
| const hasRunId = typeof args.runId === "string" && args.runId.length > 0; | ||
| const hasProjectId = typeof args.projectId === "string" && args.projectId.length > 0; | ||
| if (hasRunId === hasProjectId) { | ||
| return failText( | ||
| "Pass exactly one of runId or projectId. Use runId for one run's report (from index365_start_scan or index365_get_scan_status). Use projectId, optionally with product, for the project's latest completed report.", | ||
| ); | ||
| } | ||
| try { | ||
| const data = await apiRequest(settings, "POST", "/api/v1/runs", { | ||
| body: { | ||
| projectId: args.projectId, | ||
| scanMode: "paid_marketing_signal", | ||
| ...(args.url !== undefined ? { url: args.url } : {}), | ||
| }, | ||
| idempotencyKey: args.idempotencyKey, | ||
| fetchImpl, | ||
| }); | ||
| return ok({ | ||
| runId: data.runId, | ||
| status: data.status, | ||
| note: "Marketing Signal audit queued. Poll get_run_status until status is terminal (typically 2-5 minutes), then read get_marketing_signal_report.", | ||
| }); | ||
| if (hasRunId) return await getRunReport(args.runId); | ||
| if (args.product === "website_security") return failText(WEBSITE_SECURITY_UNAVAILABLE); | ||
| if (args.product === "marketing_signal") { | ||
| const data = await apiGet( | ||
| settings, | ||
| `/api/v1/projects/${args.projectId}/marketing/report`, | ||
| {}, | ||
| fetchImpl, | ||
| ); | ||
| return ok(withNote(data, REPORT_NEXT_NOTE)); | ||
| } | ||
| // Default product (ai_readiness): newest completed AI-Readiness run. | ||
| // The list endpoint has no product filter, so walk up to 4 pages | ||
| // (200 runs) newest-first before giving up. | ||
| let newest; | ||
| let cursor; | ||
| for (let page = 0; page < 4; page += 1) { | ||
| const runs = await apiGet( | ||
| settings, | ||
| "/api/v1/runs", | ||
| { | ||
| projectId: args.projectId, | ||
| status: "completed", | ||
| limit: MAX_LIMIT, | ||
| ...(cursor ? { cursor } : {}), | ||
| }, | ||
| fetchImpl, | ||
| ); | ||
| newest = (Array.isArray(runs?.runs) ? runs.runs : []).find( | ||
| (run) => run.product === "ai_readiness", | ||
| ); | ||
| cursor = runs?.pagination?.nextCursor; | ||
| if (newest || !cursor) break; | ||
| } | ||
| if (!newest) { | ||
| return failText( | ||
| "No completed AI-Readiness run found in this project's newest 200 completed runs. Start one with index365_start_scan, or pass product marketing_signal for the marketing report.", | ||
| ); | ||
| } | ||
| return await getRunReport(newest.runId); | ||
| } catch (err) { | ||
@@ -166,10 +425,27 @@ return fail(err); | ||
| async get_marketing_signal_report(args) { | ||
| async index365_list_findings(args) { | ||
| try { | ||
| const data = await apiGet( | ||
| settings, | ||
| `/api/v1/projects/${args.projectId}/marketing/report`, | ||
| {}, | ||
| `/api/v1/runs/${args.runId}/findings`, | ||
| { | ||
| severity: args.severity, | ||
| category: args.category, | ||
| stage: args.stage, | ||
| limit: clampLimit(args.limit) ?? 20, | ||
| cursor: args.cursor, | ||
| }, | ||
| fetchImpl, | ||
| ); | ||
| if (data?.pagination?.nextCursor) { | ||
| const shown = Array.isArray(data.findings) ? data.findings.length : 0; | ||
| const total = typeof data.findingsTotal === "number" ? data.findingsTotal : null; | ||
| const lead = | ||
| total !== null && !args.cursor && total > shown | ||
| ? `${total - shown} more findings` | ||
| : "More findings available"; | ||
| return ok( | ||
| withNote(data, `${lead}. Pass cursor, or narrow with severity=/category=/stage=.`), | ||
| ); | ||
| } | ||
| return ok(data); | ||
@@ -181,11 +457,16 @@ } catch (err) { | ||
| async list_integrations(args) { | ||
| async index365_get_finding(args) { | ||
| try { | ||
| const data = await apiGet( | ||
| settings, | ||
| `/api/v1/projects/${args.projectId}/integrations`, | ||
| `/api/v1/runs/${args.runId}/findings/${args.findingId}`, | ||
| {}, | ||
| fetchImpl, | ||
| ); | ||
| return ok(data); | ||
| const fixPrompt = composeFixPrompt(data?.finding, args); | ||
| return ok({ | ||
| ...data, | ||
| ...(fixPrompt ? { fixPrompt } : {}), | ||
| note: "Apply the fix in the local project, then start a new scan with index365_start_scan to confirm the finding is resolved.", | ||
| }); | ||
| } catch (err) { | ||
@@ -196,11 +477,13 @@ return fail(err); | ||
| async list_connected_signals(args) { | ||
| async index365_list_integrations(args) { | ||
| try { | ||
| const data = await apiGet( | ||
| settings, | ||
| `/api/v1/projects/${args.projectId}/signals`, | ||
| `/api/v1/projects/${args.projectId}/integrations`, | ||
| {}, | ||
| fetchImpl, | ||
| ); | ||
| return ok(data); | ||
| return ok( | ||
| withNote(data, "Next: index365_list_connected_signals for the imported signal rows."), | ||
| ); | ||
| } catch (err) { | ||
@@ -211,9 +494,16 @@ return fail(err); | ||
| async create_project(args) { | ||
| async index365_list_connected_signals(args) { | ||
| try { | ||
| const data = await apiRequest(settings, "POST", "/api/v1/projects", { | ||
| body: { domain: args.domain, name: args.name }, | ||
| const data = await apiGet( | ||
| settings, | ||
| `/api/v1/projects/${args.projectId}/signals`, | ||
| {}, | ||
| fetchImpl, | ||
| }); | ||
| return ok(data); | ||
| ); | ||
| return ok( | ||
| withNote( | ||
| data, | ||
| "These rows back Marketing Signal measure-stage findings. Next: index365_start_scan with product marketing_signal.", | ||
| ), | ||
| ); | ||
| } catch (err) { | ||
@@ -223,26 +513,10 @@ return fail(err); | ||
| }, | ||
| archive_project: archiveProject, | ||
| async restore_project(args) { | ||
| try { | ||
| const data = await apiRequest(settings, "PATCH", `/api/v1/projects/${args.projectId}`, { | ||
| body: { status: "active" }, | ||
| fetchImpl, | ||
| }); | ||
| return ok(data); | ||
| } catch (err) { | ||
| return fail(err); | ||
| } | ||
| }, | ||
| delete_project: archiveProject, | ||
| }; | ||
| } | ||
| /** Prompt definitions (name → build messages from args). */ | ||
| /** Prompt definitions (name -> description, argsSchema map, build messages from args). */ | ||
| export const PROMPTS = { | ||
| triage_findings: { | ||
| description: | ||
| "Triage the findings of a completed index365 audit run into a prioritized fix plan.", | ||
| "Triage the findings of a completed index365 scan run into a prioritized fix plan.", | ||
| argsSchema: { runId: "Run id to triage" }, | ||
@@ -254,3 +528,3 @@ build: ({ runId }) => [ | ||
| type: "text", | ||
| text: `Triage the index365 audit run ${runId}: call get_report_context first, then list_findings (severity=critical, then high). Group findings by category, order by user impact, and produce a prioritized fix plan with the exact remediation steps. Flag anything that needs a human decision.`, | ||
| text: `Triage the index365 scan run ${runId}: call index365_get_report first (pass the runId), then index365_list_findings (severity=critical, then high). Group findings by category, order by user impact, and produce a prioritized fix plan with the exact remediation steps. Flag anything that needs a human decision.`, | ||
| }, | ||
@@ -268,3 +542,3 @@ }, | ||
| type: "text", | ||
| text: `Read index365 run ${runId} (get_report_context, then list_findings). Map each fixable finding to concrete file changes in this repository, grouped into 1-3 reviewable PRs (smallest safe diffs first). For each PR: title, files, change summary, and which findingIds it resolves. Do not change code yet - output the plan.`, | ||
| text: `Read index365 run ${runId} (index365_get_report with the runId, then index365_list_findings). Map each fixable finding to concrete file changes in this repository, grouped into 1-3 reviewable PRs (smallest safe diffs first). For each PR: title, files, change summary, and which findingIds it resolves. Do not change code yet - output the plan.`, | ||
| }, | ||
@@ -283,3 +557,3 @@ }, | ||
| type: "text", | ||
| text: `Read index365 Marketing Signal run ${runId}: call get_report_context first (note the stage scores and sourceCoverage), then list_findings (severity=critical, then high; the stage field groups them). Produce a fix plan ordered by stage impact: Act and Measure gaps first (they waste arriving demand), then Find, Trust, Improve. For each fix: the exact remediation, the affected URL, and how to verify it. Note which claims are public-signal-only (measure stage) and would need a connected analytics account to verify fully.`, | ||
| text: `Read index365 Marketing Signal run ${runId}: call index365_get_report first with the runId (note the stage scores and sourceCoverage), then index365_list_findings (severity=critical, then high; the stage field groups them). Produce a fix plan ordered by stage impact: Act and Measure gaps first (they waste arriving demand), then Find, Trust, Improve. For each fix: the exact remediation, the affected URL, and how to verify it. Note which claims are public-signal-only (measure stage) and would need a connected analytics account to verify fully.`, | ||
| }, | ||
@@ -290,3 +564,3 @@ }, | ||
| summarize_run_for_slack: { | ||
| description: "Write a short Slack update summarizing an audit run for the team.", | ||
| description: "Write a short Slack update summarizing a scan run for the team.", | ||
| argsSchema: { runId: "Run id to summarize" }, | ||
@@ -298,3 +572,3 @@ build: ({ runId }) => [ | ||
| type: "text", | ||
| text: `Call get_report_context for index365 run ${runId} and write a 5-8 line Slack update: score with trend context, top 3 issues in plain language, what we're fixing next, and the dashboard link (humanUrl). No jargon, no markdown headers.`, | ||
| text: `Call index365_get_report for index365 run ${runId} and write a 5-8 line Slack update: score with trend context, top 3 issues in plain language, what we're fixing next, and the dashboard link (humanUrl). No jargon, no markdown headers.`, | ||
| }, | ||
@@ -304,2 +578,18 @@ }, | ||
| }, | ||
| fix_finding: { | ||
| description: "Fetch one index365 finding and apply its fix in the local project.", | ||
| argsSchema: { | ||
| runId: "Run the finding belongs to", | ||
| findingId: "Stable finding id (f_...) to fix", | ||
| }, | ||
| build: ({ runId, findingId }) => [ | ||
| { | ||
| role: "user", | ||
| content: { | ||
| type: "text", | ||
| text: `Fix index365 finding ${findingId} from run ${runId} in this repository. Call index365_get_finding with runId ${runId} and findingId ${findingId}, then follow its fixPrompt: apply the smallest safe change that resolves the finding, using the affected URLs and agentActions it specifies. Verify the change locally (build or tests as appropriate) and summarize what changed. Then suggest re-running the scan with index365_start_scan to confirm the finding is resolved.`, | ||
| }, | ||
| }, | ||
| ], | ||
| }, | ||
| }; |
+20
-1
@@ -13,3 +13,3 @@ /** Type declarations for the shared MCP tool/prompt registry (register.mjs). */ | ||
| * @param getHandlers resolves the per-call handler map (carries the API key). | ||
| * @param opts.omitTools tool names NOT to mount (remote launch omits the deprecated delete_project alias). | ||
| * @param opts.omitTools tool names NOT to mount (both transports currently mount the full 11-tool registry). | ||
| */ | ||
@@ -21,1 +21,20 @@ export function registerIndex365Tools( | ||
| ): void; | ||
| /** Pre-1.0 tool names mapped to their 1.0 successors (callable-but-unlisted grace aliases). */ | ||
| export const LEGACY_TOOL_ALIASES: Record<string, ToolName>; | ||
| /** Translate legacy call args onto the successor tool's schema (marketing tools pin their product). */ | ||
| export function translateLegacyArgs( | ||
| legacyName: string, | ||
| args: Record<string, unknown> | undefined, | ||
| ): Record<string, unknown>; | ||
| /** | ||
| * Wrap the SDK's tools/call handler so legacy names route to their successors | ||
| * with a rename note. Returns false (and logs) when the SDK handler map is not | ||
| * reachable; the real-SDK regression test guards that shape. | ||
| */ | ||
| export function attachLegacyToolAliases( | ||
| server: McpServer, | ||
| getHandlers: (extra?: unknown) => Index365Handlers, | ||
| ): boolean; |
+242
-158
@@ -14,5 +14,13 @@ import { z } from "zod"; | ||
| * `getHandlers(extra)` factory: | ||
| * stdio: () => buildHandlers(envSettings, fetchImpl) // env key, built once | ||
| * http: (extra) => buildHandlers(settingsFromBearer(extra.authInfo.token), fetch) // per request | ||
| * stdio: () => buildHandlers(envSettings, fetchImpl, { scanWaitCapSeconds: 600 }) | ||
| * http: (extra) => buildHandlers(settingsFromBearer(extra.authInfo.token), fetch, | ||
| * { scanWaitCapSeconds: 240 }) // per request | ||
| * | ||
| * MCP 1.0 (breaking rename with a grace period): exactly 11 tools are LISTED, | ||
| * all `index365_`-prefixed. Every pre-1.0 name stays CALLABLE but unlisted for | ||
| * at least 90 days (spec 2026-07-16 §5.1): a call-time alias shim rewrites the | ||
| * legacy name onto its successor and prepends a one-line rename note, so | ||
| * existing hosted connectors and published agent-skills never hard-break. | ||
| * Old -> new map lives in LEGACY_TOOL_ALIASES below and README.md (MIGRATION). | ||
| * | ||
| * Architecture lock (agent access platform plan): a THIN wrapper over the | ||
@@ -26,8 +34,8 @@ * public /api/v1 — no audit logic, no database access, no filesystem access, | ||
| export const INSTRUCTIONS = [ | ||
| "index365 runs website audits (AI-Readiness and Marketing Signal) and serves results to humans (dashboard) and agents (this server / API).", | ||
| "AI-Readiness flow: list_projects -> start_audit -> get_run_status (poll until status=completed, ~2-5 min) -> get_report_context (read FIRST, it is compact) -> list_findings / get_finding for detail.", | ||
| "Marketing Signal flow: list_projects -> run_marketing_signal_audit -> get_run_status -> get_marketing_signal_report (stage scores + source coverage) -> list_findings (stage filter works).", | ||
| "list_integrations / list_connected_signals describe the connected-source layer; providers report planned until hosted connections launch.", | ||
| "Project management: create_project adds or restores a domain-anchored project; archive_project reversibly hides one and REQUIRES the exact confirmDomain; restore_project reactivates it. delete_project is a deprecated local compatibility alias for archive_project.", | ||
| "All access is scoped to the API key's organization. start_audit and run_marketing_signal_audit spend org credits and need the runs:write scope.", | ||
| "index365 scans websites and serves prioritized findings to humans (dashboard) and agents (this server and the public API). Products: AI-Readiness (how well AI agents and AI search read the site) and Marketing Signal (can demand find, trust, act, and be measured). Website Security is not available over MCP yet.", | ||
| "Canonical loop: index365_list_projects -> index365_start_scan (waits and returns the final result; if the call is cut off, recover with index365_get_scan_status; never start again without the same idempotencyKey) -> index365_get_report -> index365_get_finding per fix -> re-scan to confirm.", | ||
| "Scans are paid (10 credits). Always pass idempotencyKey and check for an in-flight run before starting a new one.", | ||
| "Project lifecycle: index365_create_project adds a domain-anchored project; index365_archive_project reversibly hides one and REQUIRES the exact confirmDomain; index365_restore_project reactivates it.", | ||
| "index365_list_integrations and index365_list_connected_signals describe the connected-source layer; providers report planned until hosted connections launch.", | ||
| "All access is scoped to the API key's organization. index365_start_scan spends org credits and needs the runs:write scope.", | ||
| ].join(" "); | ||
@@ -61,12 +69,31 @@ | ||
| /** Products a scan can target. website_security is accepted but rejected with a curated error until its report projection ships. */ | ||
| const SCAN_PRODUCTS = ["ai_readiness", "marketing_signal", "website_security"]; | ||
| /** Products index365_get_report can serve today. */ | ||
| const REPORT_PRODUCTS = ["ai_readiness", "marketing_signal", "website_security"]; | ||
| const URL_INPUT = () => | ||
| z | ||
| .string() | ||
| .min(1) | ||
| .max(2048) | ||
| .optional() | ||
| .describe( | ||
| "The single page to scan on the project's own domain (its apex or any subdomain, e.g. staging.example.com). Absolute URL or root-relative path like '/pricing'. Omit to scan the project homepage. A cross-domain url is rejected.", | ||
| ); | ||
| /** | ||
| * Tool registry: `{ name, config }` where `config` is the exact second argument | ||
| * to `server.registerTool` (title, annotations, description, inputSchema). These | ||
| * are copied VERBATIM from the original server.mjs — do not re-author schemas or | ||
| * descriptions here; the parity test snapshots tool names + annotations, and the | ||
| * description-length test enforces the <600-char host-truncation budget. | ||
| * to `server.registerTool` (title, annotations, description, inputSchema). | ||
| * Registration order is pinned by the parity test. Descriptions keep the | ||
| * "Best for / Not for / Common mistake" skeleton plus one Example call and one | ||
| * "Next:" line each; the length test enforces the two-tier budget (<1200 chars | ||
| * for the routing-heavy index365_start_scan and index365_get_report, <600 for | ||
| * the rest). No em dashes, emoji, or exclamation points: descriptions are | ||
| * public copy. | ||
| */ | ||
| const TOOLS = [ | ||
| { | ||
| name: "list_projects", | ||
| name: "index365_list_projects", | ||
| config: { | ||
@@ -76,3 +103,3 @@ title: "List projects", | ||
| description: | ||
| "List the organization's projects (domain-anchored workspaces). Omitted status excludes archived projects; pass active, paused, archived, or all for an exact lifecycle view. Best for: resolving a projectId or finding an archived project to restore. Paginated: pass cursor from the previous page (default 20, max 50).", | ||
| "List the organization's projects (domain-anchored workspaces). Omitted status excludes archived projects; pass active, paused, archived, or all. Paginated: pass cursor from the previous page (default 20, max 50). Best for: resolving a projectId before a scan, or finding an archived project to restore. Not for: run history or scores. Common mistake: guessing a projectId instead of listing first. Example: index365_list_projects({ status: 'active' }). Next: index365_start_scan with the projectId, or index365_create_project if the domain is not listed.", | ||
| inputSchema: { | ||
@@ -95,19 +122,11 @@ limit: z | ||
| { | ||
| name: "start_audit", | ||
| name: "index365_create_project", | ||
| config: { | ||
| title: "Start an AI-readiness audit", | ||
| annotations: { readOnlyHint: false, openWorldHint: true }, | ||
| title: "Create a project", | ||
| annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true }, | ||
| description: | ||
| "Start a paid AI-Readiness audit for a project (how well AI agents and AI search read the site). Audits exactly ONE URL: pass `url` for a specific same-domain page, or omit it to audit the project homepage. Spends 10 org credits (flat) and needs the runs:write scope. Not for marketing-funnel checks (use run_marketing_signal_audit). Returns the runId immediately; then poll get_run_status. Common mistake: omitting idempotencyKey on a retry, which double-spends credits.", | ||
| "Create a domain-anchored project in the organization. Requires the projects:write scope. Idempotent by domain: creating an existing domain returns that same project (no duplicate), so a retry is safe. Best for: registering a new site before its first scan. Not for: reactivating an archived project (use index365_restore_project). Common mistake: calling index365_start_scan before the project exists. Example: index365_create_project({ domain: 'acme.com', name: 'Acme' }). Next: index365_start_scan with the returned projectId.", | ||
| inputSchema: { | ||
| projectId: z.string().uuid().describe("Project to audit (from list_projects)"), | ||
| url: z | ||
| .string() | ||
| .min(1) | ||
| .max(2048) | ||
| .optional() | ||
| .describe( | ||
| "The single page to audit on the project's own domain (its apex or any subdomain, e.g. staging.example.com). Absolute URL or root-relative path like '/pricing'. Omit to audit the project homepage. A cross-domain url is rejected.", | ||
| ), | ||
| idempotencyKey: z.string().max(255).optional().describe("Retry-safety key"), | ||
| domain: z.string().min(3).max(253).describe("Domain to scan, e.g. acme.com"), | ||
| name: z.string().max(100).optional().describe("Display name (defaults to the domain)"), | ||
| }, | ||
@@ -117,25 +136,21 @@ }, | ||
| { | ||
| name: "get_run_status", | ||
| name: "index365_archive_project", | ||
| config: { | ||
| title: "Get run status", | ||
| annotations: { readOnlyHint: true, openWorldHint: true }, | ||
| title: "Archive a project", | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: true, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| }, | ||
| description: | ||
| "Status and summary for one run: progress while running; score, severity counts, and links once completed. Best for: polling after start_audit (every ~10s is plenty) until status is terminal. Not for: the findings themselves (read get_report_context first, then list_findings). Common mistake: paging findings before the run is completed.", | ||
| inputSchema: { runId: z.string().uuid().describe("Run id") }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "list_findings", | ||
| config: { | ||
| title: "List findings", | ||
| annotations: { readOnlyHint: true, openWorldHint: true }, | ||
| description: | ||
| "Paginated findings for a completed run of either product (contract v2: stable findingId, category, severity, evidence, remediation; Marketing Signal findings also carry a stage). Best for: triaging by severity, category, or stage. Not for: full detail of one finding (use get_finding) or first orientation (read get_report_context first). Common mistake: requesting a huge limit; it is capped at 50, so page with cursor.", | ||
| "Reversibly archive a project so it leaves active lists and cannot start new scans. Scan history and API keys remain available, and index365_restore_project reactivates the same row. Requires projects:delete. Safe to retry. Best for: retiring a domain you no longer scan. Not for: permanent deletion, which this is not. Common mistake: omitting confirmDomain, which must echo the project's exact domain. Example: index365_archive_project({ projectId: '<uuid>', confirmDomain: 'acme.com' }). Next: index365_list_projects with status archived to verify.", | ||
| inputSchema: { | ||
| runId: z.string().uuid().describe("Completed run id"), | ||
| severity: z.enum(["critical", "high", "medium", "low", "info"]).optional(), | ||
| category: z.enum(FINDING_CATEGORIES).optional(), | ||
| stage: z.enum(MARKETING_STAGES).optional().describe("Marketing Signal funnel stage filter"), | ||
| limit: z.number().int().min(1).max(50).optional(), | ||
| cursor: z.string().optional(), | ||
| projectId: z | ||
| .string() | ||
| .uuid() | ||
| .describe("Project id to archive (from index365_list_projects)"), | ||
| confirmDomain: z | ||
| .string() | ||
| .describe("The project's exact domain, echoed to authorize the archive"), | ||
| }, | ||
@@ -145,18 +160,15 @@ }, | ||
| { | ||
| name: "get_finding", | ||
| name: "index365_restore_project", | ||
| config: { | ||
| title: "Get one finding", | ||
| annotations: { readOnlyHint: true, openWorldHint: true }, | ||
| title: "Restore a project", | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| }, | ||
| description: | ||
| "Full detail for one finding by its stable id (from list_findings): evidence, affected URLs, remediation, and machine-readable agentActions. Best for: pulling the exact fix instruction before editing a repo. Not for: discovery (call list_findings first to get the findingId). Common mistake: re-deriving a fix when agentActions already specifies it.", | ||
| "Restore an archived project to active status without changing its id, history, or keys. Requires projects:write. Safe to retry; an active project remains active. Best for: reactivating a domain you archived. Not for: creating a new domain (use index365_create_project). Common mistake: looking for archived ids in the default listing; pass status archived to index365_list_projects. Example: index365_restore_project({ projectId: '<uuid>' }). Next: index365_start_scan for fresh findings.", | ||
| inputSchema: { | ||
| runId: z.string().uuid().describe("Run the finding belongs to"), | ||
| // Path-interpolated into /api/v1/runs/<runId>/findings/<findingId>, so | ||
| // constrain it to a plain id (no '..', '/', '\\', '?', '#') to keep the | ||
| // request on the intended endpoint. runId/projectId are uuids already. | ||
| findingId: z | ||
| .string() | ||
| .regex(/^[A-Za-z0-9_-]+$/, "findingId must be a plain id with no path separators") | ||
| .max(128) | ||
| .describe("Stable finding id (f_...)"), | ||
| projectId: z.string().uuid().describe("Archived project id to restore"), | ||
| }, | ||
@@ -166,29 +178,27 @@ }, | ||
| { | ||
| name: "get_report_context", | ||
| name: "index365_start_scan", | ||
| config: { | ||
| title: "Get compact report context", | ||
| annotations: { readOnlyHint: true, openWorldHint: true }, | ||
| title: "Start a scan and wait for the result", | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: true, | ||
| idempotentHint: false, | ||
| openWorldHint: true, | ||
| }, | ||
| description: | ||
| "Bounded, product-aware orientation payload for a completed run (discriminate on the product field): AI-Readiness serves score, severity mix, and foundation files; Marketing Signal serves score, stage scores, and source coverage. Best for: the FIRST read after a run completes, before paging findings. Not for: per-finding detail (use get_finding). Common mistake: skipping this and dumping every finding into context.", | ||
| inputSchema: { runId: z.string().uuid().describe("Completed run id") }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "run_marketing_signal_audit", | ||
| config: { | ||
| title: "Start a Marketing Signal audit", | ||
| annotations: { readOnlyHint: false, openWorldHint: true }, | ||
| description: | ||
| "Start a paid Marketing Signal audit for a project: can demand find the site, trust the offer, act, and be measured? Public-signal scan, no analytics access. Audits exactly ONE URL: pass `url` for a specific same-domain page (e.g. /pricing), or omit it to audit the project homepage. Spends 10 org credits (flat) and needs runs:write. Not for AI/search readability (use start_audit). Returns the runId immediately; poll get_run_status. Common mistake: omitting idempotencyKey on a retry.", | ||
| "Start a paid scan and wait for the result. product: ai_readiness (default; how well AI agents and AI search read the site), marketing_signal (find, trust, act, measure), or website_security (not available over MCP yet). Scans ONE URL: pass url for a same-domain page like '/pricing', or omit it for the project homepage. Spends 10 org credits (flat) and needs the runs:write scope. This call BLOCKS: it polls internally and returns the final status, score, severity counts, and dashboard link (scans typically take 2-5 minutes). If the wait cap expires it returns a non-error still-running payload; recover with index365_get_scan_status, never by starting again. Always pass idempotencyKey: a retry with the same key attaches to the same run. Best for: the one golden-path call that produces findings. Not for: reading an existing run (index365_get_scan_status) or a finished report (index365_get_report). Common mistake: retrying after a timeout without the same idempotencyKey, which double-spends credits. Example: index365_start_scan({ projectId: '<uuid>', product: 'marketing_signal', url: '/pricing', idempotencyKey: 'fix-loop-1' }). Next: index365_get_report with the returned runId.", | ||
| inputSchema: { | ||
| projectId: z.string().uuid().describe("Project to audit (from list_projects)"), | ||
| url: z | ||
| .string() | ||
| .min(1) | ||
| .max(2048) | ||
| projectId: z.string().uuid().describe("Project to scan (from index365_list_projects)"), | ||
| product: z | ||
| .enum(SCAN_PRODUCTS) | ||
| .optional() | ||
| .describe( | ||
| "The single page to audit on the project's own domain (its apex or any subdomain, e.g. staging.example.com). Absolute URL or root-relative path like '/pricing'. Omit to audit the project homepage. A cross-domain url is rejected.", | ||
| "Scan product (default ai_readiness). website_security is not available over MCP yet.", | ||
| ), | ||
| idempotencyKey: z.string().max(255).optional().describe("Retry-safety key"), | ||
| url: URL_INPUT(), | ||
| idempotencyKey: z | ||
| .string() | ||
| .max(255) | ||
| .optional() | ||
| .describe("Retry-safety key. A retry with the same key attaches to the same run."), | ||
| }, | ||
@@ -198,41 +208,46 @@ }, | ||
| { | ||
| name: "get_marketing_signal_report", | ||
| name: "index365_get_scan_status", | ||
| config: { | ||
| title: "Get the latest Marketing Signal report", | ||
| title: "Get scan status", | ||
| annotations: { readOnlyHint: true, openWorldHint: true }, | ||
| description: | ||
| "The latest completed Marketing Signal report for a project: score, the five stage scores (find/trust/act/measure/improve), source coverage, and top findings. Best for: reading marketing results by project without a runId. Not for: AI-Readiness runs (use get_report_context). A 404 no_marketing_run means none has completed; start one with run_marketing_signal_audit.", | ||
| inputSchema: { projectId: z.string().uuid().describe("Project id") }, | ||
| "Status and summary for one run: progress while running; score, severity counts, and links once completed. Adds pollAfterSeconds and, while running, a rough etaSeconds. Scans typically take 2-5 minutes. Best for: recovering when a blocking index365_start_scan call was cut off client-side, or checking an older run. Not for: findings (index365_get_report first, then index365_list_findings). Common mistake: starting a new scan while this one is still running. Example: index365_get_scan_status({ runId: '<uuid>' }). Next: index365_get_report once status is completed.", | ||
| inputSchema: { runId: z.string().uuid().describe("Run id") }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "list_integrations", | ||
| name: "index365_get_report", | ||
| config: { | ||
| title: "List connected-signal integrations", | ||
| title: "Get the compact report", | ||
| annotations: { readOnlyHint: true, openWorldHint: true }, | ||
| description: | ||
| "Provider registry plus per-project connection status for connected signals (GA4, Search Console, Tag Manager, ads, CRM). Best for: checking what a project can connect and each provider's phase. Not for: imported signal data (use list_connected_signals). Note: providers report phase=planned until hosted connections launch, so nothing can be connected yet.", | ||
| inputSchema: { projectId: z.string().uuid().describe("Project id") }, | ||
| "Compact, product-aware report for a completed scan. Pass EXACTLY ONE of runId or projectId. runId reads that run's report. projectId reads the project's latest completed report: add product marketing_signal for the marketing report, otherwise the newest completed AI-Readiness run is used. Discriminate on the product field: AI-Readiness serves score, severity mix, and foundation files; Marketing Signal serves score, the five stage scores (find, trust, act, measure, improve), and source coverage. Best for: the FIRST read after a scan completes, before paging findings. Not for: per-finding detail (index365_get_finding) or starting scans (index365_start_scan). Common mistake: passing both runId and projectId, or dumping every finding into context without reading this first. Example: index365_get_report({ projectId: '<uuid>', product: 'marketing_signal' }). Next: index365_list_findings with the runId for the prioritized list.", | ||
| inputSchema: { | ||
| runId: z.string().uuid().optional().describe("Run id (pass this OR projectId, not both)"), | ||
| projectId: z | ||
| .string() | ||
| .uuid() | ||
| .optional() | ||
| .describe("Project id for the latest completed report (pass this OR runId, not both)"), | ||
| product: z | ||
| .enum(REPORT_PRODUCTS) | ||
| .optional() | ||
| .describe("With projectId only: which product's latest report (default ai_readiness)."), | ||
| }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "list_connected_signals", | ||
| name: "index365_list_findings", | ||
| config: { | ||
| title: "List connected-source signals", | ||
| title: "List findings", | ||
| annotations: { readOnlyHint: true, openWorldHint: true }, | ||
| description: | ||
| "Normalized signal rows imported from a project's connected sources. Best for: reading the evidence base behind Marketing Signal measure-stage findings. Not for: provider availability (use list_integrations). Note: empty with sourceCoverage=public_only until integrations launch; the surface is stable so agents never have to guess the evidence base.", | ||
| inputSchema: { projectId: z.string().uuid().describe("Project id") }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "create_project", | ||
| config: { | ||
| title: "Create a project", | ||
| annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true }, | ||
| description: | ||
| "Create a domain-anchored project in the organization, then audit it with start_audit. Requires the projects:write scope. Idempotent by domain: creating an existing domain returns that same project (no duplicate), so a retry is safe. Best for: registering a new site to audit. Common mistake: calling start_audit before the project exists.", | ||
| "Paginated findings for a completed run of either product (contract v2: stable findingId, category, severity, evidence, remediation; Marketing Signal findings also carry a stage). Best for: triaging by severity, category, or stage. Not for: full detail of one finding (index365_get_finding) or first orientation (index365_get_report first). Common mistake: requesting a huge limit; it is capped at 50, so page with cursor. Example: index365_list_findings({ runId: '<uuid>', severity: 'critical' }). Next: index365_get_finding with a findingId for the exact fix.", | ||
| inputSchema: { | ||
| domain: z.string().min(3).max(253).describe("Domain to audit, e.g. acme.com"), | ||
| name: z.string().max(100).optional().describe("Display name (defaults to the domain)"), | ||
| runId: z.string().uuid().describe("Completed run id"), | ||
| severity: z.enum(["critical", "high", "medium", "low", "info"]).optional(), | ||
| category: z.enum(FINDING_CATEGORIES).optional(), | ||
| stage: z.enum(MARKETING_STAGES).optional().describe("Marketing Signal funnel stage filter"), | ||
| limit: z.number().int().min(1).max(50).optional(), | ||
| cursor: z.string().optional(), | ||
| }, | ||
@@ -242,18 +257,18 @@ }, | ||
| { | ||
| name: "archive_project", | ||
| name: "index365_get_finding", | ||
| config: { | ||
| title: "Archive a project", | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: true, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| }, | ||
| title: "Get one finding", | ||
| annotations: { readOnlyHint: true, openWorldHint: true }, | ||
| description: | ||
| "Reversibly archive a project so it leaves active lists and cannot start new runs. Scan history and API keys remain available, and restore_project reactivates the same row. Requires projects:delete. Read first, then pass confirmDomain set to the project's exact domain. Safe to retry.", | ||
| "Full detail for one finding by its stable id (from index365_list_findings): evidence, affected URLs, remediation, machine-readable agentActions, and a composed copy-pasteable fixPrompt. Best for: pulling the exact fix instruction before editing a repo. Not for: discovery (call index365_list_findings first to get the findingId). Common mistake: re-deriving a fix when fixPrompt and agentActions already specify it. Example: index365_get_finding({ runId: '<uuid>', findingId: 'f_ab12cd34' }). Next: apply the fix, then index365_start_scan to confirm it is resolved.", | ||
| inputSchema: { | ||
| projectId: z.string().uuid().describe("Project id to archive (from list_projects)"), | ||
| confirmDomain: z | ||
| runId: z.string().uuid().describe("Run the finding belongs to"), | ||
| // Path-interpolated into /api/v1/runs/<runId>/findings/<findingId>, so | ||
| // constrain it to a plain id (no '..', '/', '\\', '?', '#') to keep the | ||
| // request on the intended endpoint. runId/projectId are uuids already. | ||
| findingId: z | ||
| .string() | ||
| .describe("The project's exact domain, echoed to authorize the archive"), | ||
| .regex(/^[A-Za-z0-9_-]+$/, "findingId must be a plain id with no path separators") | ||
| .max(128) | ||
| .describe("Stable finding id (f_...)"), | ||
| }, | ||
@@ -263,36 +278,19 @@ }, | ||
| { | ||
| name: "restore_project", | ||
| name: "index365_list_integrations", | ||
| config: { | ||
| title: "Restore a project", | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: false, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| }, | ||
| title: "List connected-signal integrations", | ||
| annotations: { readOnlyHint: true, openWorldHint: true }, | ||
| description: | ||
| "Restore an archived project to active status without changing its id, history, or keys. Requires projects:write. Find archived ids with list_projects status=archived. Safe to retry; an active project remains active.", | ||
| inputSchema: { | ||
| projectId: z.string().uuid().describe("Archived project id to restore"), | ||
| }, | ||
| "Provider registry plus per-project connection status for connected signals (GA4, Search Console, Tag Manager, ads, CRM). Best for: checking what a project can connect and each provider's phase. Not for: imported signal data (index365_list_connected_signals). Common mistake: expecting live connections; providers report phase planned until hosted connections launch. Example: index365_list_integrations({ projectId: '<uuid>' }). Next: index365_list_connected_signals for the imported rows.", | ||
| inputSchema: { projectId: z.string().uuid().describe("Project id") }, | ||
| }, | ||
| }, | ||
| { | ||
| name: "delete_project", | ||
| name: "index365_list_connected_signals", | ||
| config: { | ||
| title: "Archive a project (legacy alias)", | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: true, | ||
| idempotentHint: true, | ||
| openWorldHint: true, | ||
| }, | ||
| title: "List connected-source signals", | ||
| annotations: { readOnlyHint: true, openWorldHint: true }, | ||
| description: | ||
| "Deprecated compatibility alias for archive_project. It reversibly archives the project, preserves its history and keys, and can be undone with restore_project. Requires projects:delete. Read first, then pass confirmDomain set to the project's exact domain. New clients should call archive_project.", | ||
| inputSchema: { | ||
| projectId: z.string().uuid().describe("Project id to archive (from list_projects)"), | ||
| confirmDomain: z | ||
| .string() | ||
| .describe("The project's exact domain, echoed to authorize the archive"), | ||
| }, | ||
| "Normalized signal rows imported from a project's connected sources. Best for: reading the evidence base behind Marketing Signal measure-stage findings. Not for: provider availability (index365_list_integrations). Common mistake: treating an empty list as an error; it stays empty with sourceCoverage public_only until integrations launch. Example: index365_list_connected_signals({ projectId: '<uuid>' }). Next: index365_start_scan with product marketing_signal to score the site against these signals.", | ||
| inputSchema: { projectId: z.string().uuid().describe("Project id") }, | ||
| }, | ||
@@ -302,6 +300,87 @@ }, | ||
| /** Tool names in registration order (parity tests + the remote omit list). */ | ||
| /** Tool names in registration order (parity tests pin this exact order). */ | ||
| export const TOOL_NAMES = TOOLS.map((t) => t.name); | ||
| /** | ||
| * Pre-1.0 tool names -> their 1.0 successors. Callable-but-unlisted grace | ||
| * aliases (>=90 days from the 1.0 release). tools/list never shows these; the | ||
| * call-time shim in registerIndex365Tools rewrites the name, translates the | ||
| * product where the old tool implied one, and prepends a rename note. | ||
| */ | ||
| export const LEGACY_TOOL_ALIASES = { | ||
| list_projects: "index365_list_projects", | ||
| create_project: "index365_create_project", | ||
| archive_project: "index365_archive_project", | ||
| restore_project: "index365_restore_project", | ||
| delete_project: "index365_archive_project", | ||
| start_audit: "index365_start_scan", | ||
| run_marketing_signal_audit: "index365_start_scan", | ||
| get_run_status: "index365_get_scan_status", | ||
| get_report_context: "index365_get_report", | ||
| get_marketing_signal_report: "index365_get_report", | ||
| list_findings: "index365_list_findings", | ||
| get_finding: "index365_get_finding", | ||
| list_integrations: "index365_list_integrations", | ||
| list_connected_signals: "index365_list_connected_signals", | ||
| }; | ||
| /** Translate legacy call args onto the successor tool's schema. */ | ||
| export function translateLegacyArgs(legacyName, args) { | ||
| const input = args ?? {}; | ||
| if (legacyName === "run_marketing_signal_audit") return { ...input, product: "marketing_signal" }; | ||
| if (legacyName === "get_marketing_signal_report") | ||
| return { ...input, product: "marketing_signal" }; | ||
| return input; | ||
| } | ||
| /** | ||
| * Attach the call-time legacy alias shim by wrapping the SDK's tools/call | ||
| * request handler. Uses the low-level Server's handler map (an SDK-internal | ||
| * detail); the real-SDK regression test in __tests__/mcp.test.ts fails loudly | ||
| * if an SDK upgrade moves it. When the shape is absent (fake servers in older | ||
| * tests), this logs and degrades to no aliases rather than crashing the server. | ||
| */ | ||
| export function attachLegacyToolAliases(server, getHandlers) { | ||
| const handlerMap = server?.server?._requestHandlers; | ||
| if (!(handlerMap instanceof Map) || !handlerMap.has("tools/call")) { | ||
| console.error( | ||
| "[index365-mcp] legacy tool aliases NOT attached: tools/call handler not found (SDK shape changed?)", | ||
| ); | ||
| return false; | ||
| } | ||
| const original = handlerMap.get("tools/call"); | ||
| // Alias calls bypass McpServer's own Zod layer, so validate against the | ||
| // SUCCESSOR tool's schema here. Without this, schema-level guards (like | ||
| // get_finding's path-safe findingId regex) would not apply to legacy calls. | ||
| const schemaByTool = new Map( | ||
| TOOLS.map(({ name, config }) => [name, z.object(config.inputSchema ?? {})]), | ||
| ); | ||
| handlerMap.set("tools/call", async (request, extra) => { | ||
| const legacy = request?.params?.name; | ||
| const target = LEGACY_TOOL_ALIASES[legacy]; | ||
| if (!target) return original(request, extra); | ||
| const translated = translateLegacyArgs(legacy, request.params?.arguments); | ||
| const parsed = schemaByTool.get(target).safeParse(translated); | ||
| if (!parsed.success) { | ||
| const issues = parsed.error.issues | ||
| .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) | ||
| .join("; "); | ||
| return { | ||
| content: [ | ||
| { | ||
| type: "text", | ||
| text: `Invalid arguments for '${legacy}' (validated against its successor '${target}'): ${issues}`, | ||
| }, | ||
| ], | ||
| isError: true, | ||
| }; | ||
| } | ||
| const result = await getHandlers(extra)[target](parsed.data); | ||
| const note = `note: '${legacy}' was renamed to '${target}' in MCP 1.0. This alias is temporary; update your configuration.`; | ||
| return { ...result, content: [{ type: "text", text: note }, ...(result?.content ?? [])] }; | ||
| }); | ||
| return true; | ||
| } | ||
| /** | ||
| * Register every index365 tool + prompt on an McpServer. | ||
@@ -311,11 +390,12 @@ * | ||
| * @param getHandlers (extra) => handlerMap. Resolves the per-call handler map | ||
| * (`buildHandlers(settings, fetchImpl)` output) — this is the | ||
| * auth seam: it carries the API key. stdio passes a constant | ||
| * factory; the HTTP route reads `extra.authInfo.token`. | ||
| * @param opts.omitTools Tool names NOT to mount (remote launch: ["delete_project"]). | ||
| * (`buildHandlers(settings, fetchImpl, options)` output) — this | ||
| * is the auth seam: it carries the API key. stdio passes a | ||
| * constant factory; the HTTP route reads `extra.authInfo.token`. | ||
| * @param opts.omitTools Tool names NOT to mount. Both transports currently mount | ||
| * the full registry; the guard below keeps a future omit | ||
| * list typo-safe. | ||
| */ | ||
| export function registerIndex365Tools(server, getHandlers, opts = {}) { | ||
| // Fail loud on a typo in the omit list: a misspelled name would otherwise | ||
| // mount a tool meant to be omitted, such as the deprecated delete_project | ||
| // compatibility alias on the public remote surface. | ||
| // silently mount a tool the caller meant to hide. | ||
| const unknownOmissions = (opts.omitTools ?? []).filter((name) => !TOOL_NAMES.includes(name)); | ||
@@ -336,11 +416,15 @@ if (unknownOmissions.length) { | ||
| for (const [name, prompt] of Object.entries(PROMPTS)) { | ||
| server.registerPrompt( | ||
| name, | ||
| { | ||
| description: prompt.description, | ||
| argsSchema: { runId: z.string().describe(prompt.argsSchema.runId) }, | ||
| }, | ||
| (args) => ({ messages: prompt.build(args) }), | ||
| const argsSchema = Object.fromEntries( | ||
| Object.entries(prompt.argsSchema).map(([arg, description]) => [ | ||
| arg, | ||
| z.string().describe(description), | ||
| ]), | ||
| ); | ||
| server.registerPrompt(name, { description: prompt.description, argsSchema }, (args) => ({ | ||
| messages: prompt.build(args), | ||
| })); | ||
| } | ||
| // Grace-period aliases: pre-1.0 names stay callable (unlisted) for >=90 days. | ||
| attachLegacyToolAliases(server, getHandlers); | ||
| } |
+13
-3
@@ -23,2 +23,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| /** | ||
| * stdio can afford a longer blocking wait than a serverless route: local | ||
| * processes have no platform duration cap, so index365_start_scan may hold | ||
| * the call for up to 600s before returning the still-running payload. The | ||
| * hosted route passes 240s (inside its Vercel maxDuration window). | ||
| */ | ||
| const STDIO_SCAN_WAIT_CAP_SECONDS = 600; | ||
| export function createServer(env = process.env, fetchImpl = fetch) { | ||
@@ -29,3 +37,5 @@ const settings = resolveApiSettings(env); | ||
| // correct and cheap (the map is a set of closures, no I/O). | ||
| const handlers = buildHandlers(settings, fetchImpl); | ||
| const handlers = buildHandlers(settings, fetchImpl, { | ||
| scanWaitCapSeconds: STDIO_SCAN_WAIT_CAP_SECONDS, | ||
| }); | ||
@@ -37,4 +47,4 @@ const server = new McpServer( | ||
| // stdio mounts the full 14-tool registry, including the deprecated | ||
| // delete_project alias. The remote route omits only that alias. | ||
| // Both transports mount the identical 11-tool registry (MCP 1.0 removed the | ||
| // pre-1.0 names and aliases entirely, so there is nothing left to omit). | ||
| registerIndex365Tools(server, () => handlers); | ||
@@ -41,0 +51,0 @@ |
+1
-1
@@ -1,1 +0,1 @@ | ||
| export const SERVER_VERSION = "0.1.4"; | ||
| export const SERVER_VERSION = "1.0.0"; |
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.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
61827
60%1115
50.07%0
-100%79
64.58%7
16.67%9
12.5%