n-seo 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/.env.example +13 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/n-seo.mjs +310 -0
- package/docs/ADDING-A-SITE.md +82 -0
- package/docs/ARCHITECTURE.md +213 -0
- package/docs/DEPLOY.md +300 -0
- package/docs/FAQ.md +93 -0
- package/docs/INSTANCE.md +365 -0
- package/docs/MCP.md +104 -0
- package/docs/OPERATING-RULES.md +106 -0
- package/docs/PLAYBOOK.md +122 -0
- package/docs/PRD.md +249 -0
- package/docs/RELEASING.md +189 -0
- package/docs/SCHEDULING.md +104 -0
- package/docs/SETUP-GOOGLE.md +215 -0
- package/docs/examples/campaign.json +59 -0
- package/docs/examples/draft.md +43 -0
- package/docs/screenshots/overview.png +0 -0
- package/ingest/__pycache__/analyze_ga4.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_gsc.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_metadata.cpython-313.pyc +0 -0
- package/ingest/__pycache__/analyze_trends.cpython-313.pyc +0 -0
- package/ingest/__pycache__/google_auth.cpython-313.pyc +0 -0
- package/ingest/__pycache__/http_util.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_ga4.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_gsc.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_index_status.cpython-313.pyc +0 -0
- package/ingest/__pycache__/pull_timeseries.cpython-313.pyc +0 -0
- package/ingest/__pycache__/seo_config.cpython-313.pyc +0 -0
- package/ingest/analyze_ga4.py +79 -0
- package/ingest/analyze_gsc.py +136 -0
- package/ingest/analyze_metadata.py +158 -0
- package/ingest/analyze_trends.py +145 -0
- package/ingest/google_auth.py +238 -0
- package/ingest/http_util.py +87 -0
- package/ingest/pull_ga4.py +107 -0
- package/ingest/pull_gsc.py +111 -0
- package/ingest/pull_index_status.py +179 -0
- package/ingest/pull_timeseries.py +130 -0
- package/ingest/seo_config.py +213 -0
- package/n-seo.config.example.json +110 -0
- package/ops/__pycache__/daily.cpython-313.pyc +0 -0
- package/ops/__pycache__/daily_diff.cpython-313.pyc +0 -0
- package/ops/__pycache__/demo_data.cpython-313.pyc +0 -0
- package/ops/__pycache__/doctor.cpython-313.pyc +0 -0
- package/ops/__pycache__/export_static.cpython-313.pyc +0 -0
- package/ops/__pycache__/hn_digest.cpython-313.pyc +0 -0
- package/ops/__pycache__/indexnow.cpython-313.pyc +0 -0
- package/ops/__pycache__/llm.cpython-313.pyc +0 -0
- package/ops/__pycache__/opportunity_scan.cpython-313.pyc +0 -0
- package/ops/__pycache__/publish.cpython-313.pyc +0 -0
- package/ops/__pycache__/reddit_digest.cpython-313.pyc +0 -0
- package/ops/daily.py +250 -0
- package/ops/daily_diff.py +151 -0
- package/ops/demo_data.py +529 -0
- package/ops/doctor.py +266 -0
- package/ops/export_static.py +125 -0
- package/ops/hn_digest.py +169 -0
- package/ops/indexnow.py +107 -0
- package/ops/install-launchd.sh +76 -0
- package/ops/llm.py +139 -0
- package/ops/mcp-smoke-stdio.mjs +61 -0
- package/ops/opportunity_scan.py +185 -0
- package/ops/publish.py +158 -0
- package/ops/reddit_digest.py +168 -0
- package/ops/templates/n-seo-daily.service +11 -0
- package/ops/templates/n-seo-daily.timer +11 -0
- package/ops/templates/n-seo-dashboard.service +15 -0
- package/ops/templates/n-seo.cron +3 -0
- package/ops/templates/n-seo.daily.plist +29 -0
- package/ops/templates/n-seo.dashboard.plist +22 -0
- package/package.json +77 -0
- package/probes/__pycache__/site_probe.cpython-313.pyc +0 -0
- package/probes/site_probe.py +201 -0
- package/public/favicon.svg +6 -0
- package/public/styles.css +632 -0
- package/src/actions.ts +255 -0
- package/src/backlog.ts +197 -0
- package/src/config.ts +220 -0
- package/src/data.ts +895 -0
- package/src/insights.ts +22 -0
- package/src/mcp-stdio.ts +21 -0
- package/src/mcp.ts +490 -0
- package/src/server.tsx +260 -0
- package/src/settings.tsx +329 -0
- package/src/views.tsx +1487 -0
- package/tsconfig.json +15 -0
package/src/insights.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Optional hand-written marketer briefing: config/insights.json.
|
|
2
|
+
* Numbers render live from the latest data/trends-*.json; this file holds
|
|
3
|
+
* the interpretation. Re-read per request so edits show up immediately. */
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { INSTANCE } from "./config.js";
|
|
7
|
+
|
|
8
|
+
export interface Insight {
|
|
9
|
+
title: string;
|
|
10
|
+
verdict: "opportunity" | "warning" | "momentum" | "deprioritize";
|
|
11
|
+
body: string[];
|
|
12
|
+
move: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function insights(): { date: string; insights: Insight[] } {
|
|
16
|
+
try {
|
|
17
|
+
const raw = JSON.parse(fs.readFileSync(path.join(INSTANCE, "config", "insights.json"), "utf8"));
|
|
18
|
+
return { date: raw.date || "", insights: Array.isArray(raw.insights) ? raw.insights : [] };
|
|
19
|
+
} catch {
|
|
20
|
+
return { date: "", insights: [] };
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/mcp-stdio.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio entrypoint for the n-seo MCP server.
|
|
3
|
+
*
|
|
4
|
+
* A local MCP client (Claude Code, Claude Desktop, any MCP host) spawns this
|
|
5
|
+
* as a child process and talks over stdin/stdout. There is no network
|
|
6
|
+
* listener and no token: the OS process boundary is the authentication, and
|
|
7
|
+
* the server can only read what this user can already read. The HTTP
|
|
8
|
+
* transport in src/server.tsx exists for the cases stdio can't cover
|
|
9
|
+
* (another device, a tunnel).
|
|
10
|
+
*
|
|
11
|
+
* Nothing may be written to stdout except protocol frames, so diagnostics
|
|
12
|
+
* go to stderr.
|
|
13
|
+
*/
|
|
14
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
15
|
+
import { createMcpServer } from "./mcp.js";
|
|
16
|
+
|
|
17
|
+
const server = createMcpServer();
|
|
18
|
+
const transport = new StdioServerTransport();
|
|
19
|
+
|
|
20
|
+
await server.connect(transport);
|
|
21
|
+
console.error("n-seo MCP server ready on stdio");
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server over the n-seo data layer.
|
|
3
|
+
*
|
|
4
|
+
* Read-only by design. The queue is edited in config/backlog.json (or via the
|
|
5
|
+
* dashboard's buttons); an agent reaching this server gets the same view you
|
|
6
|
+
* get on the dashboard, not a way to change it. Every tool is annotated
|
|
7
|
+
* readOnlyHint.
|
|
8
|
+
*
|
|
9
|
+
* All the real data lives in data/ on this machine, so this server only makes
|
|
10
|
+
* sense running locally, next to the files. Two transports use it:
|
|
11
|
+
* src/mcp-stdio.ts (a local client spawns it; the OS process boundary is the
|
|
12
|
+
* auth) and POST /mcp on the dashboard (bearer token; see src/server.tsx).
|
|
13
|
+
*/
|
|
14
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { ROOT, INSTANCE, SITES, config, siteByHost, MODULE_INFO, engineInfo } from "./config.js";
|
|
19
|
+
import { allActions, score, type Action } from "./actions.js";
|
|
20
|
+
import * as data from "./data.js";
|
|
21
|
+
|
|
22
|
+
/** Tools hand back JSON as text: compact, and every client can read it. */
|
|
23
|
+
const json = (value: unknown) => ({
|
|
24
|
+
content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const fail = (message: string) => ({
|
|
28
|
+
content: [{ type: "text" as const, text: message }],
|
|
29
|
+
isError: true,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const knownHosts = () => SITES().map((s) => s.host).join(", ") || "(no sites configured)";
|
|
33
|
+
|
|
34
|
+
const clip = (s: string | undefined, n: number) =>
|
|
35
|
+
!s ? "" : s.length <= n ? s : s.slice(0, n - 1) + "…";
|
|
36
|
+
|
|
37
|
+
/** Ranked the way the dashboard ranks: impact per unit of effort. */
|
|
38
|
+
const ranked = (list: Action[]) => [...list].sort((a, b) => score(b) - score(a));
|
|
39
|
+
|
|
40
|
+
export function createMcpServer(): McpServer {
|
|
41
|
+
const server = new McpServer(
|
|
42
|
+
{ name: "n-seo", version: "0.1.0" },
|
|
43
|
+
{
|
|
44
|
+
instructions:
|
|
45
|
+
"Read-only access to a local SEO control plane: the action queue, " +
|
|
46
|
+
"per-site Search Console / GA4 metrics, the metadata audit, live-site " +
|
|
47
|
+
"probe health, the daily ops log, and machine-generated opportunity " +
|
|
48
|
+
"proposals. Decisions ride the 90-day window; 16-month data is for " +
|
|
49
|
+
"totals and history. Impact numbers order the queue — they are not " +
|
|
50
|
+
"forecasts. After a title/description change, that page's metadata is " +
|
|
51
|
+
"frozen for 28 days. Nothing here writes: propose changes to the " +
|
|
52
|
+
"user, don't apply them.",
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const readOnly = { readOnlyHint: true, openWorldHint: false };
|
|
57
|
+
|
|
58
|
+
/* ---------- the queue ---------- */
|
|
59
|
+
|
|
60
|
+
server.registerTool(
|
|
61
|
+
"list_actions",
|
|
62
|
+
{
|
|
63
|
+
title: "List the action queue",
|
|
64
|
+
description:
|
|
65
|
+
"The single source of 'what's next'. Active items are unstarted; " +
|
|
66
|
+
"watching items have shipped and are being measured. Ranked by " +
|
|
67
|
+
"impact per unit of effort.",
|
|
68
|
+
inputSchema: {
|
|
69
|
+
status: z.enum(["active", "watching", "all"]).default("active")
|
|
70
|
+
.describe("active = not yet done, watching = shipped and being measured"),
|
|
71
|
+
host: z.string().optional().describe(`filter to one site (${knownHosts()})`),
|
|
72
|
+
tag: z.string().optional().describe("e.g. metadata, ctr-gap, striking, hygiene, engagement, content, distribution"),
|
|
73
|
+
limit: z.number().int().min(1).max(100).default(25),
|
|
74
|
+
},
|
|
75
|
+
annotations: readOnly,
|
|
76
|
+
},
|
|
77
|
+
async ({ status, host, tag, limit }) => {
|
|
78
|
+
let list = allActions();
|
|
79
|
+
if (status === "active") list = list.filter((a) => !a.watching);
|
|
80
|
+
if (status === "watching") list = list.filter((a) => a.watching);
|
|
81
|
+
if (host) list = list.filter((a) => a.host === host);
|
|
82
|
+
if (tag) list = list.filter((a) => a.tag === tag);
|
|
83
|
+
const rows = ranked(list).slice(0, limit).map((a, i) => ({
|
|
84
|
+
rank: i + 1,
|
|
85
|
+
id: a.id,
|
|
86
|
+
title: a.title,
|
|
87
|
+
host: a.host,
|
|
88
|
+
tag: a.tag,
|
|
89
|
+
impact: a.impact,
|
|
90
|
+
effort: a.effort,
|
|
91
|
+
kind: a.kind,
|
|
92
|
+
source: a.source,
|
|
93
|
+
why: clip(a.why, 240),
|
|
94
|
+
watching: a.watching ? clip(a.watching, 240) : undefined,
|
|
95
|
+
}));
|
|
96
|
+
return json({ matched: list.length, returned: rows.length, actions: rows });
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
server.registerTool(
|
|
101
|
+
"get_action",
|
|
102
|
+
{
|
|
103
|
+
title: "Get one action in full",
|
|
104
|
+
description:
|
|
105
|
+
"Full record for a single queue item including its spec and success " +
|
|
106
|
+
"criteria. Match by id, or case-insensitive substring on the title.",
|
|
107
|
+
inputSchema: { title: z.string().describe("action id, or a substring of the title") },
|
|
108
|
+
annotations: readOnly,
|
|
109
|
+
},
|
|
110
|
+
async ({ title }) => {
|
|
111
|
+
const q = title.toLowerCase();
|
|
112
|
+
const all = allActions();
|
|
113
|
+
const byId = all.find((a) => a.id === title);
|
|
114
|
+
if (byId) return json(byId);
|
|
115
|
+
const hits = all.filter((a) => a.title.toLowerCase().includes(q));
|
|
116
|
+
if (hits.length === 0) return fail(`No action matching "${title}".`);
|
|
117
|
+
if (hits.length > 1 && !hits.some((h) => h.title.toLowerCase() === q)) {
|
|
118
|
+
return json({
|
|
119
|
+
ambiguous: true,
|
|
120
|
+
matches: hits.map((h) => ({ id: h.id, title: h.title, host: h.host })),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return json(hits.find((h) => h.title.toLowerCase() === q) ?? hits[0]);
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
/* ---------- sites + per-site metrics ---------- */
|
|
128
|
+
|
|
129
|
+
server.registerTool(
|
|
130
|
+
"list_sites",
|
|
131
|
+
{
|
|
132
|
+
title: "List the configured sites",
|
|
133
|
+
description:
|
|
134
|
+
"Every site this control plane governs, with its 16-month Search Console " +
|
|
135
|
+
"totals, 28-day session trend, and current probe status.",
|
|
136
|
+
inputSchema: {},
|
|
137
|
+
annotations: readOnly,
|
|
138
|
+
},
|
|
139
|
+
async () => {
|
|
140
|
+
const probe = data.latestProbe();
|
|
141
|
+
return json({
|
|
142
|
+
probed_at: probe?.probed_at ?? null,
|
|
143
|
+
sites: SITES().map((s) => {
|
|
144
|
+
const p = data.probeFor(s);
|
|
145
|
+
const trend = data.sessionTrend(s);
|
|
146
|
+
return {
|
|
147
|
+
host: s.host,
|
|
148
|
+
gscProperty: s.gscProperty ?? null,
|
|
149
|
+
ga4Property: s.ga4Property ?? null,
|
|
150
|
+
repo: s.repo ?? null,
|
|
151
|
+
hosting: s.hosting ?? null,
|
|
152
|
+
gsc16mo: data.gscSummary(s),
|
|
153
|
+
sessions28d: trend.recent,
|
|
154
|
+
sessionsPrior28d: trend.prior,
|
|
155
|
+
probeHealthy: p ? data.probeHealthy(p) : null,
|
|
156
|
+
probeFindings: data.probeFindings(p),
|
|
157
|
+
};
|
|
158
|
+
}),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
server.registerTool(
|
|
164
|
+
"site_report",
|
|
165
|
+
{
|
|
166
|
+
title: "Per-site report",
|
|
167
|
+
description:
|
|
168
|
+
"Search Console totals, traffic mix (search vs AI assistants vs direct), " +
|
|
169
|
+
"session trend, top landing pages, referrals from your other sites, and " +
|
|
170
|
+
"probe findings for one site.",
|
|
171
|
+
inputSchema: {
|
|
172
|
+
host: z.string().describe(knownHosts()),
|
|
173
|
+
landingLimit: z.number().int().min(1).max(50).default(10),
|
|
174
|
+
},
|
|
175
|
+
annotations: readOnly,
|
|
176
|
+
},
|
|
177
|
+
async ({ host, landingLimit }) => {
|
|
178
|
+
const site = siteByHost(host);
|
|
179
|
+
if (!site) return fail(`Unknown host "${host}". Known: ${knownHosts()}`);
|
|
180
|
+
return json({
|
|
181
|
+
host: site.host,
|
|
182
|
+
repo: site.repo ?? null,
|
|
183
|
+
hosting: site.hosting ?? null,
|
|
184
|
+
gsc16mo: data.gscSummary(site),
|
|
185
|
+
trafficMix90d: data.trafficMix(site),
|
|
186
|
+
sessionTrend28d: data.sessionTrend(site),
|
|
187
|
+
landingPages: data.landingPages(site).slice(0, landingLimit),
|
|
188
|
+
crossReferrals: data.crossReferrals(site),
|
|
189
|
+
probe: data.probeFor(site) ?? null,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
server.registerTool(
|
|
195
|
+
"top_queries",
|
|
196
|
+
{
|
|
197
|
+
title: "Top search queries for a site",
|
|
198
|
+
description:
|
|
199
|
+
"Search Console queries with clicks, impressions, CTR and position. Use the " +
|
|
200
|
+
"90-day window for decisions; 16-month is for totals and history.",
|
|
201
|
+
inputSchema: {
|
|
202
|
+
host: z.string().describe(knownHosts()),
|
|
203
|
+
window: z.enum(["90d", "16mo"]).default("90d"),
|
|
204
|
+
limit: z.number().int().min(1).max(200).default(25),
|
|
205
|
+
},
|
|
206
|
+
annotations: readOnly,
|
|
207
|
+
},
|
|
208
|
+
async ({ host, window, limit }) => {
|
|
209
|
+
const site = siteByHost(host);
|
|
210
|
+
if (!site) return fail(`Unknown host "${host}". Known: ${knownHosts()}`);
|
|
211
|
+
const rows = data.queries(site, window === "90d").slice(0, limit)
|
|
212
|
+
.map((r) => ({ query: r.keys[0], clicks: r.clicks, impressions: r.impressions, ctr: r.ctr, position: r.position }));
|
|
213
|
+
return json({ host: site.host, window, count: rows.length, queries: rows });
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
server.registerTool(
|
|
218
|
+
"striking_distance",
|
|
219
|
+
{
|
|
220
|
+
title: "Striking-distance queries",
|
|
221
|
+
description:
|
|
222
|
+
"Queries ranking just off page one (position 5–15), where a small move " +
|
|
223
|
+
"converts existing impressions into clicks. 90-day window.",
|
|
224
|
+
inputSchema: {
|
|
225
|
+
host: z.string().describe(knownHosts()),
|
|
226
|
+
minImpressions: z.number().int().min(1).default(10),
|
|
227
|
+
limit: z.number().int().min(1).max(100).default(25),
|
|
228
|
+
},
|
|
229
|
+
annotations: readOnly,
|
|
230
|
+
},
|
|
231
|
+
async ({ host, minImpressions, limit }) => {
|
|
232
|
+
const site = siteByHost(host);
|
|
233
|
+
if (!site) return fail(`Unknown host "${host}". Known: ${knownHosts()}`);
|
|
234
|
+
const rows = data.strikingDistance(site, minImpressions).slice(0, limit)
|
|
235
|
+
.map((r) => ({ query: r.keys[0], clicks: r.clicks, impressions: r.impressions, ctr: r.ctr, position: r.position }));
|
|
236
|
+
return json({ host: site.host, count: rows.length, queries: rows });
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
server.registerTool(
|
|
241
|
+
"ctr_gaps",
|
|
242
|
+
{
|
|
243
|
+
title: "Queries whose CTR trails their position",
|
|
244
|
+
description:
|
|
245
|
+
"Ranking well but under-clicked — usually a title/description problem. " +
|
|
246
|
+
"`expected` is the CTR the position would normally earn.",
|
|
247
|
+
inputSchema: {
|
|
248
|
+
host: z.string().describe(knownHosts()),
|
|
249
|
+
minImpressions: z.number().int().min(1).default(30),
|
|
250
|
+
limit: z.number().int().min(1).max(100).default(25),
|
|
251
|
+
},
|
|
252
|
+
annotations: readOnly,
|
|
253
|
+
},
|
|
254
|
+
async ({ host, minImpressions, limit }) => {
|
|
255
|
+
const site = siteByHost(host);
|
|
256
|
+
if (!site) return fail(`Unknown host "${host}". Known: ${knownHosts()}`);
|
|
257
|
+
const rows = data.ctrGaps(site, minImpressions).slice(0, limit)
|
|
258
|
+
.map((r) => ({ query: r.keys[0], clicks: r.clicks, impressions: r.impressions, ctr: r.ctr, expected: r.expected, position: r.position }));
|
|
259
|
+
return json({ host: site.host, count: rows.length, queries: rows });
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
server.registerTool(
|
|
264
|
+
"metadata_audit",
|
|
265
|
+
{
|
|
266
|
+
title: "Metadata audit findings",
|
|
267
|
+
description:
|
|
268
|
+
"Per-page title/description findings from the daily crawl. Note the " +
|
|
269
|
+
"hard rules before acting: 28-day freeze after any title change, and " +
|
|
270
|
+
"at most ~8 metadata changes per week across all sites.",
|
|
271
|
+
inputSchema: { host: z.string().optional().describe(`one of ${knownHosts()}; omit for all`) },
|
|
272
|
+
annotations: readOnly,
|
|
273
|
+
},
|
|
274
|
+
async ({ host }) => {
|
|
275
|
+
const audit = data.metadataAudit();
|
|
276
|
+
if (!audit) return fail("No metadata audit on disk yet — run python3 ingest/analyze_metadata.py.");
|
|
277
|
+
if (!host) return json(audit);
|
|
278
|
+
const findings = audit.sites[host];
|
|
279
|
+
if (!findings) return fail(`No audit entry for "${host}". Known: ${Object.keys(audit.sites).join(", ")}`);
|
|
280
|
+
return json({ generated: audit.generated, host, findings });
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
/* ---------- trends ---------- */
|
|
285
|
+
|
|
286
|
+
server.registerTool(
|
|
287
|
+
"trends_timeseries",
|
|
288
|
+
{
|
|
289
|
+
title: "Daily clicks and sessions",
|
|
290
|
+
description:
|
|
291
|
+
"Per-day Google clicks and GA4 sessions for a site over the last N days, " +
|
|
292
|
+
"for spotting inflections the 90-day aggregate hides.",
|
|
293
|
+
inputSchema: {
|
|
294
|
+
host: z.string().describe(knownHosts()),
|
|
295
|
+
days: z.number().int().min(7).max(180).default(90),
|
|
296
|
+
},
|
|
297
|
+
annotations: readOnly,
|
|
298
|
+
},
|
|
299
|
+
async ({ host, days }) => {
|
|
300
|
+
const site = siteByHost(host);
|
|
301
|
+
if (!site) return fail(`Unknown host "${host}". Known: ${knownHosts()}`);
|
|
302
|
+
const dates = data.lastNDates(days);
|
|
303
|
+
const clicks = new Map<string, number>();
|
|
304
|
+
for (const r of data.gscTimeseries(site)) {
|
|
305
|
+
clicks.set(r.date, (clicks.get(r.date) ?? 0) + r.clicks);
|
|
306
|
+
}
|
|
307
|
+
const sessions = new Map<string, number>();
|
|
308
|
+
for (const r of data.ga4Timeseries(site)) {
|
|
309
|
+
sessions.set(r.date, (sessions.get(r.date) ?? 0) + r.sessions);
|
|
310
|
+
}
|
|
311
|
+
return json({
|
|
312
|
+
host: site.host,
|
|
313
|
+
days,
|
|
314
|
+
series: dates.map((d) => ({
|
|
315
|
+
date: d,
|
|
316
|
+
clicks: clicks.get(d) ?? 0,
|
|
317
|
+
sessions: sessions.get(d) ?? 0,
|
|
318
|
+
})),
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
/* ---------- ops health, log, proposals ---------- */
|
|
324
|
+
|
|
325
|
+
server.registerTool(
|
|
326
|
+
"ops_status",
|
|
327
|
+
{
|
|
328
|
+
title: "Batch and data health",
|
|
329
|
+
description:
|
|
330
|
+
"Whether the last daily run succeeded, how stale each dataset is, and " +
|
|
331
|
+
"the current probe results. Check this before trusting any numbers.",
|
|
332
|
+
inputSchema: {},
|
|
333
|
+
annotations: readOnly,
|
|
334
|
+
},
|
|
335
|
+
async () => {
|
|
336
|
+
const probe = data.latestProbe();
|
|
337
|
+
return json({
|
|
338
|
+
lastRun: data.lastRun(),
|
|
339
|
+
server: data.processFreshness(),
|
|
340
|
+
freshness: data.dataFreshness(),
|
|
341
|
+
probe: probe
|
|
342
|
+
? {
|
|
343
|
+
probed_at: probe.probed_at,
|
|
344
|
+
healthy: probe.sites.filter((s) => data.probeHealthy(s)).length,
|
|
345
|
+
total: probe.sites.length,
|
|
346
|
+
sites: probe.sites.map((s) => ({ host: s.site, findings: data.probeFindings(s) })),
|
|
347
|
+
}
|
|
348
|
+
: null,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
server.registerTool(
|
|
354
|
+
"daily_log",
|
|
355
|
+
{
|
|
356
|
+
title: "Recent daily-ops log entries",
|
|
357
|
+
description:
|
|
358
|
+
"What the batch observed each day: probe health, watched-page numbers, " +
|
|
359
|
+
"conversions, cross-referrals. Newest first.",
|
|
360
|
+
inputSchema: { days: z.number().int().min(1).max(30).default(5) },
|
|
361
|
+
annotations: readOnly,
|
|
362
|
+
},
|
|
363
|
+
async ({ days }) => {
|
|
364
|
+
const all = data.dailyLogSections();
|
|
365
|
+
return json({ entries: all.slice(0, days) });
|
|
366
|
+
}
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
server.registerTool(
|
|
370
|
+
"opportunity_proposals",
|
|
371
|
+
{
|
|
372
|
+
title: "Machine-proposed work awaiting review",
|
|
373
|
+
description:
|
|
374
|
+
"Riser detection plus (with the LLM module on) inferred proposals from " +
|
|
375
|
+
"the daily scan, and verdicts on watched items. Proposals never " +
|
|
376
|
+
"self-promote into the queue — the user accepts them on the dashboard.",
|
|
377
|
+
inputSchema: {},
|
|
378
|
+
annotations: readOnly,
|
|
379
|
+
},
|
|
380
|
+
async () => {
|
|
381
|
+
const scan = data.opportunityScan();
|
|
382
|
+
if (!scan) return fail("No opportunity scan on disk yet — run python3 ops/opportunity_scan.py.");
|
|
383
|
+
return json(scan);
|
|
384
|
+
}
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
server.registerTool(
|
|
388
|
+
"conversions_status",
|
|
389
|
+
{
|
|
390
|
+
title: "Conversion instrumentation",
|
|
391
|
+
description:
|
|
392
|
+
"Whether the configured GA4 key events (signups, leads…) are flowing, " +
|
|
393
|
+
"their 28-day counts, and the primary event by source.",
|
|
394
|
+
inputSchema: {},
|
|
395
|
+
annotations: readOnly,
|
|
396
|
+
},
|
|
397
|
+
async () => json(data.funnelSummary())
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
server.registerTool(
|
|
401
|
+
"campaigns",
|
|
402
|
+
{
|
|
403
|
+
title: "Outreach campaigns",
|
|
404
|
+
description:
|
|
405
|
+
"Standing outreach plays from content/campaigns/: ranked targets with " +
|
|
406
|
+
"contact routes and pitch angles, templates, and the send plan. Omit " +
|
|
407
|
+
"slug to list; pass it for one campaign in full.",
|
|
408
|
+
inputSchema: {
|
|
409
|
+
slug: z.string().optional(),
|
|
410
|
+
onlySubmitted: z.boolean().default(false).describe("only targets with a status set"),
|
|
411
|
+
},
|
|
412
|
+
annotations: readOnly,
|
|
413
|
+
},
|
|
414
|
+
async ({ slug, onlySubmitted }) => {
|
|
415
|
+
const all = data.campaigns();
|
|
416
|
+
if (!slug) {
|
|
417
|
+
return json(all.map((c) => ({ slug: c.slug, name: c.name, site: c.site, targets: c.targets.length, templates: c.templates.length, planned: c.plan.length })));
|
|
418
|
+
}
|
|
419
|
+
const c = all.find((x) => x.slug === slug);
|
|
420
|
+
if (!c) return fail(`No campaign "${slug}". Known: ${all.map((x) => x.slug).join(", ") || "(none)"}`);
|
|
421
|
+
const targets = onlySubmitted ? c.targets.filter((t) => t.status) : c.targets;
|
|
422
|
+
return json({ ...c, targets });
|
|
423
|
+
}
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
server.registerTool(
|
|
427
|
+
"settings",
|
|
428
|
+
{
|
|
429
|
+
title: "Current configuration (no secrets)",
|
|
430
|
+
description: "Name, sites, auth mode, and which modules are enabled. Never returns keys or tokens.",
|
|
431
|
+
inputSchema: {},
|
|
432
|
+
annotations: readOnly,
|
|
433
|
+
},
|
|
434
|
+
async () => {
|
|
435
|
+
const cfg = config();
|
|
436
|
+
return json({
|
|
437
|
+
name: cfg.name,
|
|
438
|
+
port: cfg.port,
|
|
439
|
+
googleAuthMode: cfg.google.auth,
|
|
440
|
+
sites: cfg.sites.map((s) => ({ host: s.host, gscProperty: s.gscProperty ?? null, ga4Property: s.ga4Property ?? null, brand: s.brand ?? null })),
|
|
441
|
+
watchPages: cfg.watchPages,
|
|
442
|
+
conversions: cfg.conversions ?? null,
|
|
443
|
+
modules: Object.fromEntries(MODULE_INFO.map((m) => [m.key, !!cfg.modules[m.key]?.enabled])),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
/* ---------- docs as resources ---------- */
|
|
449
|
+
|
|
450
|
+
server.registerTool(
|
|
451
|
+
"engine_info",
|
|
452
|
+
{
|
|
453
|
+
title: "Engine and instance",
|
|
454
|
+
description:
|
|
455
|
+
"Which n-seo engine is running (version, commit, path), which instance directory it serves, " +
|
|
456
|
+
"and which modules are on. Check this before assuming a feature exists.",
|
|
457
|
+
inputSchema: {},
|
|
458
|
+
annotations: readOnly,
|
|
459
|
+
},
|
|
460
|
+
async () => {
|
|
461
|
+
const cfg = config();
|
|
462
|
+
return json({
|
|
463
|
+
...engineInfo(),
|
|
464
|
+
modules: Object.fromEntries(MODULE_INFO.map((m) => [m.key, !!cfg.modules[m.key]?.enabled])),
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
/* Engine docs (playbook, rules) ship with the code; the daily log is the
|
|
470
|
+
instance's own history. */
|
|
471
|
+
const doc = (name: string, file: string, description: string, base: string = ROOT) =>
|
|
472
|
+
server.registerResource(
|
|
473
|
+
name,
|
|
474
|
+
`seo://docs/${name}`,
|
|
475
|
+
{ title: file, description, mimeType: "text/markdown" },
|
|
476
|
+
async (uri) => {
|
|
477
|
+
const p = path.join(base, "docs", file);
|
|
478
|
+
if (!fs.existsSync(p)) throw new Error(`${file} not found`);
|
|
479
|
+
return {
|
|
480
|
+
contents: [{ uri: uri.href, mimeType: "text/markdown", text: fs.readFileSync(p, "utf8") }],
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
doc("playbook", "PLAYBOOK.md", "The SEO / AEO / GEO strategy this queue implements.");
|
|
486
|
+
doc("daily-log", "daily-log.md", "Full daily ops log, one entry per day.", INSTANCE);
|
|
487
|
+
doc("operating-rules", "OPERATING-RULES.md", "The rules that keep the loop honest: freeze windows, batching, what the numbers mean.");
|
|
488
|
+
|
|
489
|
+
return server;
|
|
490
|
+
}
|