linkaudit-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/bin/cli.js +36 -0
- package/package.json +35 -0
- package/src/client.js +60 -0
- package/src/lint.js +66 -0
- package/src/server.js +159 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# linkaudit-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [LinkAudit](https://linkaudit.app) — scan any website's AI-search visibility (ChatGPT, Perplexity, Gemini) from inside Claude, Cursor, or your CI pipeline.
|
|
4
|
+
|
|
5
|
+
Perplexity's citation check is **measured**, not estimated: it sends real, unbranded buyer questions to the live Perplexity API and checks whether the domain actually gets cited. Everything else in the scan is clearly labelled measured or estimated — never presented as more certain than it is.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
### Claude Code
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
claude mcp add linkaudit -- npx -y linkaudit-mcp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### Claude Desktop
|
|
16
|
+
|
|
17
|
+
Add to your `claude_desktop_config.json`:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"linkaudit": {
|
|
23
|
+
"command": "npx",
|
|
24
|
+
"args": ["-y", "linkaudit-mcp"]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Cursor
|
|
31
|
+
|
|
32
|
+
Add to `.cursor/mcp.json`:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"linkaudit": {
|
|
38
|
+
"command": "npx",
|
|
39
|
+
"args": ["-y", "linkaudit-mcp"]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Tools
|
|
46
|
+
|
|
47
|
+
| Tool | What it does |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `run_scan` | Full AI-visibility scan: SEO/GEO scores, per-engine ChatGPT/Perplexity/Gemini visibility, top-priority fixes. |
|
|
50
|
+
| `get_fix_pack` | Ready-to-paste files: robots.txt, schema, sitemap, llms.txt, agents.json — built only from real data on the page. |
|
|
51
|
+
| `check_citability` | Is this page "citable" by AI engines? Answer-first structure, quotable passages, question-led headings, FAQ schema. |
|
|
52
|
+
| `check_perplexity_citations` | Measured check: does Perplexity actually cite this domain for real buyer questions? |
|
|
53
|
+
|
|
54
|
+
Anonymous use shares the same free-scan limit as linkaudit.app/scan (it's your own machine's IP). Set `LINKAUDIT_API_KEY` for a higher limit once you have one.
|
|
55
|
+
|
|
56
|
+
## CI: lint your GEO score
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npx linkaudit-mcp lint https://example.com --min-geo 70 --min-citability 60
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Exits non-zero (and prints the failing checks) if either score is below the threshold — drop it into a GitHub Actions step to gate merges on AI-search visibility the same way you gate on tests.
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
- name: Check AI-search visibility
|
|
66
|
+
run: npx linkaudit-mcp lint https://your-site.com --min-geo 70
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Environment variables
|
|
70
|
+
|
|
71
|
+
| Var | Default | Purpose |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `LINKAUDIT_API_BASE` | `https://linkaudit.app` | API base URL |
|
|
74
|
+
| `LINKAUDIT_API_KEY` | *(none)* | Optional bearer token, forwarded as `Authorization` |
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
|
|
5
|
+
if (args[0] === 'lint') {
|
|
6
|
+
require('../src/lint')
|
|
7
|
+
.lint(args.slice(1))
|
|
8
|
+
.catch((err) => {
|
|
9
|
+
console.error(err.message);
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
});
|
|
12
|
+
} else if (args[0] === '--help' || args[0] === '-h') {
|
|
13
|
+
console.log(`linkaudit-mcp — MCP server + CLI for LinkAudit (linkaudit.app)
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
linkaudit-mcp Start the MCP server on stdio
|
|
17
|
+
linkaudit-mcp lint <url> [options] Run a one-off GEO/citability check
|
|
18
|
+
|
|
19
|
+
Lint options:
|
|
20
|
+
--min-geo <N> Exit non-zero if the GEO score is below N
|
|
21
|
+
--min-citability <N> Exit non-zero if the citability score is below N
|
|
22
|
+
|
|
23
|
+
Env:
|
|
24
|
+
LINKAUDIT_API_BASE API base URL (default: https://linkaudit.app)
|
|
25
|
+
LINKAUDIT_API_KEY Optional bearer token, forwarded as Authorization
|
|
26
|
+
|
|
27
|
+
See https://linkaudit.app/learn/mcp.html for setup with Claude Code, Claude
|
|
28
|
+
Desktop, and Cursor.`);
|
|
29
|
+
} else {
|
|
30
|
+
require('../src/server')
|
|
31
|
+
.start()
|
|
32
|
+
.catch((err) => {
|
|
33
|
+
console.error('LinkAudit MCP server failed to start:', err.message);
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
});
|
|
36
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "linkaudit-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for LinkAudit — scan any website's AI-search visibility (ChatGPT, Perplexity, Gemini), get ready-to-paste fix files, and lint GEO/citability scores in CI.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"linkaudit-mcp": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/server.js",
|
|
10
|
+
"files": ["bin", "src", "README.md"],
|
|
11
|
+
"engines": { "node": ">=18" },
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"model-context-protocol",
|
|
16
|
+
"seo",
|
|
17
|
+
"geo",
|
|
18
|
+
"generative-engine-optimization",
|
|
19
|
+
"ai-search",
|
|
20
|
+
"chatgpt",
|
|
21
|
+
"perplexity",
|
|
22
|
+
"claude",
|
|
23
|
+
"linkaudit"
|
|
24
|
+
],
|
|
25
|
+
"homepage": "https://linkaudit.app",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/RohanRatwani/linkaudit_landing",
|
|
29
|
+
"directory": "mcp"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
33
|
+
"zod": "^3.25.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client over LinkAudit's public /api/scan endpoint. No scan logic is
|
|
3
|
+
* duplicated here — every tool in this package calls the same live API a
|
|
4
|
+
* browser user hits at linkaudit.app/scan.
|
|
5
|
+
*
|
|
6
|
+
* Anonymous calls are subject to the same per-IP free-scan limit as the web
|
|
7
|
+
* UI (this runs from the developer's own machine, so that's the expected,
|
|
8
|
+
* fair behavior — not a workaround). Setting LINKAUDIT_API_KEY raises that
|
|
9
|
+
* limit once API-key auth is wired up server-side (not yet — v0.1 tolerates
|
|
10
|
+
* its absence entirely).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const API_BASE = process.env.LINKAUDIT_API_BASE || 'https://linkaudit.app';
|
|
14
|
+
const API_KEY = process.env.LINKAUDIT_API_KEY || null;
|
|
15
|
+
|
|
16
|
+
// Small in-memory cache so a single MCP session calling run_scan, then
|
|
17
|
+
// get_fix_pack, then check_citability on the same URL doesn't pay for three
|
|
18
|
+
// separate scans — each tool independently calls runScan() and slices what
|
|
19
|
+
// it needs from the same result.
|
|
20
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
21
|
+
const cache = new Map(); // url -> { result, ts }
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} url
|
|
25
|
+
* @param {{ roast?: boolean }} [opts] - roast defaults to false: it costs an
|
|
26
|
+
* extra AI call and none of the four tools need the joke text.
|
|
27
|
+
* @returns {Promise<Object>} the full /api/scan response
|
|
28
|
+
*/
|
|
29
|
+
async function runScan(url, { roast = false } = {}) {
|
|
30
|
+
const cached = cache.get(url);
|
|
31
|
+
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.result;
|
|
32
|
+
|
|
33
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
34
|
+
if (API_KEY) headers.Authorization = `Bearer ${API_KEY}`;
|
|
35
|
+
|
|
36
|
+
let res;
|
|
37
|
+
try {
|
|
38
|
+
res = await fetch(`${API_BASE}/api/scan`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers,
|
|
41
|
+
body: JSON.stringify({ url, includeGeo: true, roast }),
|
|
42
|
+
});
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw new Error(`Could not reach ${API_BASE}: ${err.message}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const data = await res.json().catch(() => ({}));
|
|
48
|
+
|
|
49
|
+
if (!res.ok || data.success === false) {
|
|
50
|
+
const err = new Error(data.error || `Scan failed (HTTP ${res.status})`);
|
|
51
|
+
err.rateLimited = !!data.rate_limited;
|
|
52
|
+
err.status = res.status;
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
cache.set(url, { result: data, ts: Date.now() });
|
|
57
|
+
return data;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { runScan, API_BASE };
|
package/src/lint.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `linkaudit-mcp lint <url>` — CI-friendly GEO/citability check. Prints the
|
|
3
|
+
* top-priority findings and exits non-zero if either score falls below the
|
|
4
|
+
* given threshold, so a build can gate on AI-search visibility the same way
|
|
5
|
+
* it gates on tests or lint.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { runScan } = require('./client');
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const url = argv[0];
|
|
12
|
+
let minGeo = 0;
|
|
13
|
+
let minCitability = 0;
|
|
14
|
+
for (let i = 1; i < argv.length; i++) {
|
|
15
|
+
if (argv[i] === '--min-geo') minGeo = Number(argv[++i]) || 0;
|
|
16
|
+
if (argv[i] === '--min-citability') minCitability = Number(argv[++i]) || 0;
|
|
17
|
+
}
|
|
18
|
+
return { url, minGeo, minCitability };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function lint(argv) {
|
|
22
|
+
const { url, minGeo, minCitability } = parseArgs(argv);
|
|
23
|
+
if (!url) {
|
|
24
|
+
console.error('Usage: linkaudit-mcp lint <url> [--min-geo N] [--min-citability N]');
|
|
25
|
+
process.exitCode = 1;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(`Scanning ${url} ...`);
|
|
30
|
+
let r;
|
|
31
|
+
try {
|
|
32
|
+
r = await runScan(url);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error(`Scan failed: ${err.message}`);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const geoScore = r.scores?.geo_overall ?? 0;
|
|
40
|
+
const citabilityScore = r.agents?.geo_citation?.citability?.score ?? 0;
|
|
41
|
+
|
|
42
|
+
console.log(`\nGEO score: ${geoScore}/100 Citability: ${citabilityScore}/100\n`);
|
|
43
|
+
console.log('Fix these first:');
|
|
44
|
+
const top = (r.top_priorities || []).slice(0, 5);
|
|
45
|
+
if (!top.length) {
|
|
46
|
+
console.log(' (none — nothing found, or the scan degraded to defaults)');
|
|
47
|
+
}
|
|
48
|
+
top.forEach((rec, i) => {
|
|
49
|
+
console.log(` ${i + 1}. [${rec.impact || '?'} impact / ${rec.effort || '?'} effort] ${rec.title || rec.message}`);
|
|
50
|
+
if (rec.fix) console.log(` -> ${rec.fix}`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const failures = [];
|
|
54
|
+
if (minGeo && geoScore < minGeo) failures.push(`GEO score ${geoScore} is below the required ${minGeo}`);
|
|
55
|
+
if (minCitability && citabilityScore < minCitability) failures.push(`Citability score ${citabilityScore} is below the required ${minCitability}`);
|
|
56
|
+
|
|
57
|
+
if (failures.length) {
|
|
58
|
+
console.error(`\nFAILED:\n - ${failures.join('\n - ')}`);
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log('\nOK — thresholds met.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { lint };
|
package/src/server.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LinkAudit MCP server (stdio). Registers four tools, each a thin slice of
|
|
3
|
+
* the same /api/scan response — see client.js for the shared fetch + cache.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
7
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
8
|
+
const { z } = require('zod');
|
|
9
|
+
const { runScan, API_BASE } = require('./client');
|
|
10
|
+
|
|
11
|
+
function attribution(url) {
|
|
12
|
+
return `\n\nFull report: ${API_BASE}/scan?url=${encodeURIComponent(url)}&utm_source=mcp`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function textResult(obj, url) {
|
|
16
|
+
return { content: [{ type: 'text', text: JSON.stringify(obj, null, 2) + attribution(url) }] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function errorResult(err) {
|
|
20
|
+
const text = err.rateLimited
|
|
21
|
+
? `${err.message} (LinkAudit's free-scan limit was reached for this IP — try again later, or sign in at linkaudit.app for more scans.)`
|
|
22
|
+
: err.message;
|
|
23
|
+
return { content: [{ type: 'text', text }], isError: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function registerTools(server) {
|
|
27
|
+
server.registerTool(
|
|
28
|
+
'run_scan',
|
|
29
|
+
{
|
|
30
|
+
description:
|
|
31
|
+
"Scan a website and return its AI-visibility scores: traditional SEO, GEO (Generative Engine Optimization), and per-engine ChatGPT/Perplexity/Gemini visibility. Perplexity's number is MEASURED (a real, unbranded citation check against the live Perplexity API), the others are estimated from page signals and labelled as such. Also returns the top-priority fixes, ranked by impact for least effort.",
|
|
32
|
+
inputSchema: { url: z.string().url().describe('The URL to scan, e.g. https://example.com') },
|
|
33
|
+
},
|
|
34
|
+
async ({ url }) => {
|
|
35
|
+
try {
|
|
36
|
+
const r = await runScan(url);
|
|
37
|
+
return textResult(
|
|
38
|
+
{
|
|
39
|
+
url: r.url,
|
|
40
|
+
domain: r.domain,
|
|
41
|
+
scan_id: r.scan_id,
|
|
42
|
+
scores: r.scores,
|
|
43
|
+
ai_visibility: r.ai_visibility
|
|
44
|
+
? {
|
|
45
|
+
ai_visibility_score: r.ai_visibility.ai_visibility_score,
|
|
46
|
+
grade: r.ai_visibility.grade,
|
|
47
|
+
ai_engines: r.ai_visibility.ai_engines,
|
|
48
|
+
}
|
|
49
|
+
: null,
|
|
50
|
+
top_priorities: r.top_priorities,
|
|
51
|
+
},
|
|
52
|
+
url,
|
|
53
|
+
);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return errorResult(err);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
server.registerTool(
|
|
61
|
+
'get_fix_pack',
|
|
62
|
+
{
|
|
63
|
+
description:
|
|
64
|
+
'Get ready-to-paste fix files for a website: robots.txt, schema markup, sitemap, llms.txt, and an agents.json manifest that lets AI agents read the business\'s services/hours/FAQ directly. Every file is built only from data actually found on the page — nothing is invented.',
|
|
65
|
+
inputSchema: { url: z.string().url().describe('The URL to build a fix pack for') },
|
|
66
|
+
},
|
|
67
|
+
async ({ url }) => {
|
|
68
|
+
try {
|
|
69
|
+
const r = await runScan(url);
|
|
70
|
+
return textResult(
|
|
71
|
+
{
|
|
72
|
+
url: r.url,
|
|
73
|
+
domain: r.domain,
|
|
74
|
+
files: (r.fix_pack?.files || []).map((f) => ({
|
|
75
|
+
id: f.id,
|
|
76
|
+
filename: f.filename,
|
|
77
|
+
title: f.title,
|
|
78
|
+
status: f.status, // 'add' | 'improve' | 'ok'
|
|
79
|
+
place_at: f.place_at,
|
|
80
|
+
why: f.why,
|
|
81
|
+
sop: f.sop,
|
|
82
|
+
contents: f.contents,
|
|
83
|
+
})),
|
|
84
|
+
},
|
|
85
|
+
url,
|
|
86
|
+
);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return errorResult(err);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
server.registerTool(
|
|
94
|
+
'check_citability',
|
|
95
|
+
{
|
|
96
|
+
description:
|
|
97
|
+
'Check whether a page is "citable" by AI answer engines: does it answer-first, contain self-contained quotable passages, use question-led headings, and carry FAQ schema? Returns a 0-100 sub-score plus the specific failing rules with fixes.',
|
|
98
|
+
inputSchema: { url: z.string().url() },
|
|
99
|
+
},
|
|
100
|
+
async ({ url }) => {
|
|
101
|
+
try {
|
|
102
|
+
const r = await runScan(url);
|
|
103
|
+
return textResult(
|
|
104
|
+
{
|
|
105
|
+
url: r.url,
|
|
106
|
+
geo_score: r.scores?.geo_overall ?? null,
|
|
107
|
+
citability: r.agents?.geo_citation?.citability || null,
|
|
108
|
+
},
|
|
109
|
+
url,
|
|
110
|
+
);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return errorResult(err);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
server.registerTool(
|
|
118
|
+
'check_perplexity_citations',
|
|
119
|
+
{
|
|
120
|
+
description:
|
|
121
|
+
'A MEASURED (not estimated) check of whether Perplexity actually cites this domain: sends real, unbranded buyer questions to the live Perplexity Sonar API and checks whether the domain appears in the citations Perplexity actually returned. Reports cited/not-cited, share of voice, and which competing sources got cited instead.',
|
|
122
|
+
inputSchema: { url: z.string().url() },
|
|
123
|
+
},
|
|
124
|
+
async ({ url }) => {
|
|
125
|
+
try {
|
|
126
|
+
const r = await runScan(url);
|
|
127
|
+
const measured = r.ai_visibility?.ai_engines?.perplexity;
|
|
128
|
+
const probe = r.ai_probe?.probes?.perplexity_citation;
|
|
129
|
+
return textResult(
|
|
130
|
+
{
|
|
131
|
+
url: r.url,
|
|
132
|
+
measured: !!measured?.measured,
|
|
133
|
+
cited: measured?.cited ?? probe?.cited ?? null,
|
|
134
|
+
share_of_voice: measured?.share_of_voice ?? probe?.share_of_voice ?? null,
|
|
135
|
+
queries_tested: probe?.queries_tested ?? null,
|
|
136
|
+
queries_citing: probe?.queries_citing ?? null,
|
|
137
|
+
sample_query: probe?.sample_query ?? null,
|
|
138
|
+
sources: probe?.sources || [],
|
|
139
|
+
reason: measured?.reason || probe?.reason || null,
|
|
140
|
+
},
|
|
141
|
+
url,
|
|
142
|
+
);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return errorResult(err);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function start() {
|
|
151
|
+
const server = new McpServer({ name: 'linkaudit', version: '0.1.0' });
|
|
152
|
+
registerTools(server);
|
|
153
|
+
|
|
154
|
+
const transport = new StdioServerTransport();
|
|
155
|
+
await server.connect(transport);
|
|
156
|
+
console.error('LinkAudit MCP server running on stdio');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { start, registerTools };
|