kura-mcp-admin 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 +109 -0
- package/bin/server.mjs +27 -0
- package/lib/config.ts +52 -0
- package/package.json +39 -0
- package/server.ts +154 -0
- package/tools.ts +414 -0
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# kura-mcp-admin
|
|
2
|
+
|
|
3
|
+
MCP server exposing Kura's **admin** orchestrator endpoints as first-class tool calls for Claude Desktop, Cursor, and Claude Code.
|
|
4
|
+
|
|
5
|
+
This is the admin counterpart to [`kura-mcp-agent-import`](https://www.npmjs.com/package/kura-mcp-agent-import) (which is customer-facing, scoped to `/api/agent/*`). The admin MCP gives Ben's agent full project management — list projects, edit pages, update brand kits, trigger deploys, manage media, purge caches, read SEO data.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Use via `npx` (no global install needed):
|
|
10
|
+
|
|
11
|
+
```jsonc
|
|
12
|
+
// ~/.claude/.mcp.json (or Claude Desktop / Cursor MCP settings)
|
|
13
|
+
{
|
|
14
|
+
"mcpServers": {
|
|
15
|
+
"kura-admin": {
|
|
16
|
+
"command": "npx",
|
|
17
|
+
"args": ["-y", "kura-mcp-admin"],
|
|
18
|
+
"env": {
|
|
19
|
+
"KURA_API_KEY": "kura_<48-hex>",
|
|
20
|
+
"KURA_BASE_URL": "https://orchestrator-production-1d88.up.railway.app"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or install globally:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install -g kura-mcp-admin
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Get the API key
|
|
34
|
+
|
|
35
|
+
The `KURA_API_KEY` must have **`admin-full`** scope. Mint one at:
|
|
36
|
+
**[www.kurawebsites.com/admin/settings/api-keys](https://www.kurawebsites.com/admin/settings/api-keys)**
|
|
37
|
+
|
|
38
|
+
Customer-scope (`agent-import`) keys are rejected by the orchestrator with `403 insufficient_scope` on every admin route. Use the customer MCP server (`kura-mcp-agent-import`) for those keys.
|
|
39
|
+
|
|
40
|
+
## Tools (11)
|
|
41
|
+
|
|
42
|
+
### Projects
|
|
43
|
+
|
|
44
|
+
| Tool | Args | What it does |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| `kura_list_projects` | `{ includeArchived? }` | List all your projects (id, slug, status, framework, timestamps) |
|
|
47
|
+
| `kura_get_project` | `{ projectId }` | Full project metadata including brand kit, design DNA, domain status |
|
|
48
|
+
|
|
49
|
+
### Brand kit
|
|
50
|
+
|
|
51
|
+
| Tool | Args | What it does |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `kura_get_brand_kit` | `{ projectId }` | Read current brand kit JSON |
|
|
54
|
+
| `kura_update_brand_kit` | `{ projectId, brandKit }` | Replace brand kit (full object — see schema in tools.ts) |
|
|
55
|
+
|
|
56
|
+
### Pages
|
|
57
|
+
|
|
58
|
+
| Tool | Args | What it does |
|
|
59
|
+
| --- | --- | --- |
|
|
60
|
+
| `kura_list_pages` | `{ projectId }` | List all pages with id/slug/title/type/status |
|
|
61
|
+
| `kura_get_page` | `{ projectId, slug }` | Read full HTML content + metadata |
|
|
62
|
+
| `kura_update_page` | `{ projectId, slug, content }` | Replace page HTML (writes to live) |
|
|
63
|
+
|
|
64
|
+
### Deploy
|
|
65
|
+
|
|
66
|
+
| Tool | Args | What it does |
|
|
67
|
+
| --- | --- | --- |
|
|
68
|
+
| `kura_trigger_deploy` | `{ projectId, message? }` | Publish current state to live |
|
|
69
|
+
|
|
70
|
+
### Media + ops
|
|
71
|
+
|
|
72
|
+
| Tool | Args | What it does |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `kura_list_media` | `{ projectId }` | Project's media library |
|
|
75
|
+
| `kura_purge_cache` | `{ projectId }` | Bust Cloudways caches |
|
|
76
|
+
| `kura_list_tracked_keywords` | `{ projectId }` | SEO keywords + latest rank positions |
|
|
77
|
+
|
|
78
|
+
## Common workflows
|
|
79
|
+
|
|
80
|
+
**Find a project, then operate on it:**
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
1. kura_list_projects() → find the projectId
|
|
84
|
+
2. kura_get_project({ projectId }) → see metadata
|
|
85
|
+
3. kura_list_pages({ projectId }) → see pages
|
|
86
|
+
4. kura_get_page({ projectId, slug: 'home' }) → read content
|
|
87
|
+
5. kura_update_page({ projectId, slug: 'home', content: '<new html>' })
|
|
88
|
+
6. kura_trigger_deploy({ projectId, message: 'Update hero copy' })
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Brand kit refresh:**
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
1. kura_get_brand_kit({ projectId }) → see current kit
|
|
95
|
+
2. kura_update_brand_kit({ projectId, brandKit: {...full new kit...} })
|
|
96
|
+
3. kura_purge_cache({ projectId }) → ensure fresh assets land
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Protocol
|
|
100
|
+
|
|
101
|
+
Newline-delimited JSON-RPC 2.0 over stdio. Methods: `initialize`, `tools/list`, `tools/call`, `notifications/initialized`, `notifications/cancelled`, `ping`. Implemented directly without `@modelcontextprotocol/sdk` for zero deps beyond `tsx`.
|
|
102
|
+
|
|
103
|
+
## What's not (yet) included
|
|
104
|
+
|
|
105
|
+
Per the [admin agent integration design](https://github.com/benbybee/kura-web/blob/main/docs/plans/2026-05-13-admin-agent-integration.md), the full surface includes form submissions, change requests, and audit log writes — those route through kura-web's API (not orchestrator) and will land in a v0.2 follow-up that adds orchestrator passthrough routes.
|
|
106
|
+
|
|
107
|
+
## Source
|
|
108
|
+
|
|
109
|
+
[github.com/benbybee/kura-orchestrator/tree/main/mcp/admin](https://github.com/benbybee/kura-orchestrator/tree/main/mcp/admin).
|
package/bin/server.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bin wrapper for the kura-mcp-admin server. Same tsx-runtime pattern
|
|
3
|
+
// as the agent-import MCP — no build step at install time.
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { resolve, dirname } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const server = resolve(here, '..', 'server.ts');
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
|
|
14
|
+
let tsxBin;
|
|
15
|
+
try {
|
|
16
|
+
tsxBin = require.resolve('tsx/dist/cli.mjs');
|
|
17
|
+
} catch {
|
|
18
|
+
tsxBin = require.resolve('tsx');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const child = spawn(process.execPath, [tsxBin, server, ...process.argv.slice(2)], {
|
|
22
|
+
stdio: 'inherit',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
child.on('exit', (code) => {
|
|
26
|
+
process.exit(code ?? 1);
|
|
27
|
+
});
|
package/lib/config.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Local copy of the CLI's CliConfig type + loader. Duplicated here so
|
|
6
|
+
// the MCP package is publishable without depending on kura-cli (avoiding
|
|
7
|
+
// the chicken-and-egg of "install kura-cli to install kura-mcp").
|
|
8
|
+
//
|
|
9
|
+
// Read order:
|
|
10
|
+
// 1. KURA_API_KEY + KURA_BASE_URL env vars (highest precedence)
|
|
11
|
+
// 2. ~/.kurarc JSON file: { "api_key": "kura_…", "base_url": "https://…" }
|
|
12
|
+
// 3. Fallback base URL: production Railway orchestrator
|
|
13
|
+
|
|
14
|
+
export type CliConfig = {
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
apiKey: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const DEFAULT_BASE_URL = 'https://orchestrator-production-1d88.up.railway.app';
|
|
20
|
+
|
|
21
|
+
export async function loadConfig(): Promise<CliConfig> {
|
|
22
|
+
const envKey = process.env['KURA_API_KEY']?.trim();
|
|
23
|
+
const envUrl = process.env['KURA_BASE_URL']?.trim();
|
|
24
|
+
|
|
25
|
+
let fileKey: string | undefined;
|
|
26
|
+
let fileUrl: string | undefined;
|
|
27
|
+
try {
|
|
28
|
+
const raw = await readFile(join(homedir(), '.kurarc'), 'utf-8');
|
|
29
|
+
const parsed = JSON.parse(raw) as { api_key?: string; base_url?: string };
|
|
30
|
+
fileKey = parsed.api_key?.trim();
|
|
31
|
+
fileUrl = parsed.base_url?.trim();
|
|
32
|
+
} catch {
|
|
33
|
+
// missing or unreadable; env-only is fine
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const apiKey = envKey ?? fileKey;
|
|
37
|
+
if (!apiKey) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'No Kura API key found. Set KURA_API_KEY env var or create ~/.kurarc with { "api_key": "kura_..." }.\n' +
|
|
40
|
+
'Generate a key at https://www.kurawebsites.com/account/api-keys',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (!/^kura_[0-9a-f]{48}$/.test(apiKey)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`KURA_API_KEY format invalid (expected kura_<48 hex>); got "${apiKey.slice(0, 12)}…"`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
baseUrl: (envUrl ?? fileUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''),
|
|
50
|
+
apiKey,
|
|
51
|
+
};
|
|
52
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kura-mcp-admin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server exposing Kura's admin orchestrator endpoints as first-class tool calls for Claude Desktop, Cursor, and Claude Code. Requires an admin-full scope API key.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Kura",
|
|
8
|
+
"homepage": "https://www.kurawebsites.com/agent-docs",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/benbybee/kura-orchestrator",
|
|
12
|
+
"directory": "mcp/admin"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"kura",
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"admin",
|
|
19
|
+
"claude-desktop",
|
|
20
|
+
"cursor",
|
|
21
|
+
"claude-code"
|
|
22
|
+
],
|
|
23
|
+
"bin": {
|
|
24
|
+
"kura-mcp-admin": "bin/server.mjs"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin/server.mjs",
|
|
28
|
+
"server.ts",
|
|
29
|
+
"tools.ts",
|
|
30
|
+
"lib/**/*.ts",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"tsx": "^4.21.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/server.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Minimal MCP-protocol stdio server for the kura-mcp-admin tools.
|
|
2
|
+
// Same protocol implementation as mcp/agent-import/server.ts (no SDK
|
|
3
|
+
// dep). 11 tools covering project list/get, brand kit get/update,
|
|
4
|
+
// pages list/get/update, deploy trigger, media list, cache purge,
|
|
5
|
+
// keywords list.
|
|
6
|
+
//
|
|
7
|
+
// Requires an admin-full scope API key (from /admin/settings/api-keys).
|
|
8
|
+
// Customer-scope keys are rejected by the orchestrator with 403
|
|
9
|
+
// insufficient_scope.
|
|
10
|
+
|
|
11
|
+
import * as readline from 'node:readline';
|
|
12
|
+
import { loadConfig, type CliConfig } from './lib/config.js';
|
|
13
|
+
import { TOOL_SCHEMAS, callTool } from './tools.js';
|
|
14
|
+
|
|
15
|
+
const SERVER_INFO = { name: 'kura-mcp-admin', version: '0.1.0' };
|
|
16
|
+
const CAPABILITIES = { tools: {} };
|
|
17
|
+
|
|
18
|
+
type JsonRpcRequest = {
|
|
19
|
+
jsonrpc: '2.0';
|
|
20
|
+
id?: number | string | null;
|
|
21
|
+
method: string;
|
|
22
|
+
params?: unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type JsonRpcResponse =
|
|
26
|
+
| { jsonrpc: '2.0'; id: number | string | null; result: unknown }
|
|
27
|
+
| {
|
|
28
|
+
jsonrpc: '2.0';
|
|
29
|
+
id: number | string | null;
|
|
30
|
+
error: { code: number; message: string; data?: unknown };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export async function runMcpServer(opts?: {
|
|
34
|
+
cfgOverride?: CliConfig;
|
|
35
|
+
input?: NodeJS.ReadableStream;
|
|
36
|
+
output?: NodeJS.WritableStream;
|
|
37
|
+
}): Promise<void> {
|
|
38
|
+
const cfg = opts?.cfgOverride ?? (await loadConfig());
|
|
39
|
+
const input = opts?.input ?? process.stdin;
|
|
40
|
+
const output = opts?.output ?? process.stdout;
|
|
41
|
+
const rl = readline.createInterface({ input, terminal: false });
|
|
42
|
+
|
|
43
|
+
for await (const line of rl) {
|
|
44
|
+
const trimmed = line.trim();
|
|
45
|
+
if (!trimmed) continue;
|
|
46
|
+
|
|
47
|
+
let req: JsonRpcRequest;
|
|
48
|
+
try {
|
|
49
|
+
req = JSON.parse(trimmed);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
writeResponse(output, {
|
|
52
|
+
jsonrpc: '2.0',
|
|
53
|
+
id: null,
|
|
54
|
+
error: { code: -32700, message: `parse_error: ${(err as Error).message}` },
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const id = req.id ?? null;
|
|
60
|
+
const isNotification = req.id === undefined;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
switch (req.method) {
|
|
64
|
+
case 'initialize':
|
|
65
|
+
if (isNotification) break;
|
|
66
|
+
writeResponse(output, {
|
|
67
|
+
jsonrpc: '2.0',
|
|
68
|
+
id,
|
|
69
|
+
result: {
|
|
70
|
+
protocolVersion: '2024-11-05',
|
|
71
|
+
capabilities: CAPABILITIES,
|
|
72
|
+
serverInfo: SERVER_INFO,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
break;
|
|
76
|
+
case 'tools/list':
|
|
77
|
+
if (isNotification) break;
|
|
78
|
+
writeResponse(output, {
|
|
79
|
+
jsonrpc: '2.0',
|
|
80
|
+
id,
|
|
81
|
+
result: { tools: Object.values(TOOL_SCHEMAS) },
|
|
82
|
+
});
|
|
83
|
+
break;
|
|
84
|
+
case 'tools/call': {
|
|
85
|
+
if (isNotification) break;
|
|
86
|
+
const params = (req.params ?? {}) as {
|
|
87
|
+
name?: string;
|
|
88
|
+
arguments?: unknown;
|
|
89
|
+
};
|
|
90
|
+
if (typeof params.name !== 'string') {
|
|
91
|
+
writeResponse(output, {
|
|
92
|
+
jsonrpc: '2.0',
|
|
93
|
+
id,
|
|
94
|
+
error: { code: -32602, message: 'invalid_params: name required' },
|
|
95
|
+
});
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
const result = await callTool(params.name, params.arguments, { cfg });
|
|
99
|
+
writeResponse(output, {
|
|
100
|
+
jsonrpc: '2.0',
|
|
101
|
+
id,
|
|
102
|
+
result: {
|
|
103
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
104
|
+
isError: !result.ok,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case 'notifications/initialized':
|
|
110
|
+
case 'notifications/cancelled':
|
|
111
|
+
break;
|
|
112
|
+
case 'ping':
|
|
113
|
+
if (isNotification) break;
|
|
114
|
+
writeResponse(output, { jsonrpc: '2.0', id, result: {} });
|
|
115
|
+
break;
|
|
116
|
+
default:
|
|
117
|
+
if (isNotification) break;
|
|
118
|
+
writeResponse(output, {
|
|
119
|
+
jsonrpc: '2.0',
|
|
120
|
+
id,
|
|
121
|
+
error: { code: -32601, message: `method_not_found: ${req.method}` },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (!isNotification) {
|
|
126
|
+
writeResponse(output, {
|
|
127
|
+
jsonrpc: '2.0',
|
|
128
|
+
id,
|
|
129
|
+
error: {
|
|
130
|
+
code: -32603,
|
|
131
|
+
message: `internal_error: ${(err as Error).message}`,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
process.stderr.write(
|
|
136
|
+
`[kura-mcp-admin] error in method "${req.method}": ${(err as Error).message}\n`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function writeResponse(out: NodeJS.WritableStream, resp: JsonRpcResponse): void {
|
|
143
|
+
out.write(JSON.stringify(resp) + '\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const invokedDirectly =
|
|
147
|
+
process.argv[1] &&
|
|
148
|
+
import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href;
|
|
149
|
+
if (invokedDirectly) {
|
|
150
|
+
runMcpServer().catch((err) => {
|
|
151
|
+
process.stderr.write(`[kura-mcp-admin] fatal: ${(err as Error).message}\n`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
});
|
|
154
|
+
}
|
package/tools.ts
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import type { CliConfig } from './lib/config.js';
|
|
2
|
+
|
|
3
|
+
// ---------- kura-mcp-admin tool implementations ----------
|
|
4
|
+
//
|
|
5
|
+
// 11 tools wrapping Kura orchestrator admin endpoints. Used by Ben's
|
|
6
|
+
// local Claude Code (or any MCP-aware client) to manage existing
|
|
7
|
+
// projects from inside an agent session.
|
|
8
|
+
//
|
|
9
|
+
// Auth: admin-full scope API key (see /admin/settings/api-keys in
|
|
10
|
+
// kura-web). Tools that need scope='admin-full' WILL be rejected with
|
|
11
|
+
// 403 insufficient_scope if the user's key is scope='agent-import'.
|
|
12
|
+
//
|
|
13
|
+
// All tools return ToolResult — the MCP server wraps in the
|
|
14
|
+
// { content: [...], isError } shape MCP clients expect.
|
|
15
|
+
|
|
16
|
+
export type ToolResult =
|
|
17
|
+
| { ok: true; data: unknown }
|
|
18
|
+
| { ok: false; error: string; data?: unknown };
|
|
19
|
+
|
|
20
|
+
export type ToolContext = { cfg: CliConfig };
|
|
21
|
+
|
|
22
|
+
// ---------- Tool schemas (advertised via tools/list) ----------
|
|
23
|
+
|
|
24
|
+
export const TOOL_SCHEMAS = {
|
|
25
|
+
// Projects
|
|
26
|
+
kura_list_projects: {
|
|
27
|
+
name: 'kura_list_projects',
|
|
28
|
+
description:
|
|
29
|
+
"List every project the authenticated admin owns. Returns each project's id, slug, businessName, status, platform, framework, liveUrl, customDomain, and timestamps. Use this as the entry point to find a projectId before calling project-scoped tools.",
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
includeArchived: {
|
|
34
|
+
type: 'boolean',
|
|
35
|
+
description: 'Include archived projects in the result. Default: false.',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
kura_get_project: {
|
|
41
|
+
name: 'kura_get_project',
|
|
42
|
+
description:
|
|
43
|
+
'Get a single project by id with full metadata (brand kit, design DNA, domain status, lock state, etc.). Encrypted fields are returned as "<encrypted>" placeholders.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: { projectId: { type: 'string' } },
|
|
47
|
+
required: ['projectId'],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
// Brand kit
|
|
51
|
+
kura_get_brand_kit: {
|
|
52
|
+
name: 'kura_get_brand_kit',
|
|
53
|
+
description:
|
|
54
|
+
"Read the project's brand kit JSON (colors, fonts, type scale, radii, logo). Returns null if the brand kit hasn't been set yet.",
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: { projectId: { type: 'string' } },
|
|
58
|
+
required: ['projectId'],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
kura_update_brand_kit: {
|
|
62
|
+
name: 'kura_update_brand_kit',
|
|
63
|
+
description:
|
|
64
|
+
"Replace the project's brand kit. Pass the FULL brand kit object (not a patch). On success the orchestrator projects the new tokens to WP (for WP-canonical) or writes the tokens.css file (for github-files) automatically.",
|
|
65
|
+
inputSchema: {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties: {
|
|
68
|
+
projectId: { type: 'string' },
|
|
69
|
+
brandKit: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
description:
|
|
72
|
+
'Full brand kit object. Shape: { version: 1, colors: {primary, accent, ink, surface, border}, fonts: {heading, body}, typeScale: {h1,h2,h3,body}, radii: {sm,md,lg,full}, spacing?: {xs,sm,md,lg,xl}, logo: {mediaId, alt} | null, favicon?: {mediaId} | null }',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
required: ['projectId', 'brandKit'],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
// Pages
|
|
79
|
+
kura_list_pages: {
|
|
80
|
+
name: 'kura_list_pages',
|
|
81
|
+
description:
|
|
82
|
+
"List all pages on a project. Returns each page's id, title, slug, type (home/about/services/...), and status. Works for both WP-canonical and github-files projects via the platform-adapter abstraction.",
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: 'object',
|
|
85
|
+
properties: { projectId: { type: 'string' } },
|
|
86
|
+
required: ['projectId'],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
kura_get_page: {
|
|
90
|
+
name: 'kura_get_page',
|
|
91
|
+
description:
|
|
92
|
+
"Read a single page's full HTML content + metadata. For github-files projects this returns the raw file content. For WP projects this returns the rendered HTML body.",
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
projectId: { type: 'string' },
|
|
97
|
+
slug: {
|
|
98
|
+
type: 'string',
|
|
99
|
+
description: 'Page slug or platform-native id (URL-encoded if needed).',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
required: ['projectId', 'slug'],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
kura_update_page: {
|
|
106
|
+
name: 'kura_update_page',
|
|
107
|
+
description:
|
|
108
|
+
"Replace a page's full HTML content. Returns the new revisionId for optimistic concurrency. WARNING: this writes to live — there's no draft step. For edits that should land via the visual editor pipeline (with data-kura-id anchoring), use /api/edit/* endpoints instead.",
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
projectId: { type: 'string' },
|
|
113
|
+
slug: { type: 'string' },
|
|
114
|
+
content: {
|
|
115
|
+
type: 'string',
|
|
116
|
+
description: 'Full new HTML body (post-shortcode for WP).',
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
required: ['projectId', 'slug', 'content'],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
// Deploy
|
|
123
|
+
kura_trigger_deploy: {
|
|
124
|
+
name: 'kura_trigger_deploy',
|
|
125
|
+
description:
|
|
126
|
+
"Publish the project's current draft state to live. For WP-canonical projects this materialises any pending kura_edits CSS and pushes via WP REST. For github-files projects this commits the working tree to the configured branch + deploys via Cloudways.",
|
|
127
|
+
inputSchema: {
|
|
128
|
+
type: 'object',
|
|
129
|
+
properties: {
|
|
130
|
+
projectId: { type: 'string' },
|
|
131
|
+
message: {
|
|
132
|
+
type: 'string',
|
|
133
|
+
description: 'Optional commit/deploy message for the audit trail.',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
required: ['projectId'],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
// Media
|
|
140
|
+
kura_list_media: {
|
|
141
|
+
name: 'kura_list_media',
|
|
142
|
+
description:
|
|
143
|
+
"List a project's media library (images, videos, SVGs). Returns each item's id, url, mime, width/height (for images), size, and alt text. Includes WP-imported items and Kura-uploaded ones.",
|
|
144
|
+
inputSchema: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: { projectId: { type: 'string' } },
|
|
147
|
+
required: ['projectId'],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
// Cache
|
|
151
|
+
kura_purge_cache: {
|
|
152
|
+
name: 'kura_purge_cache',
|
|
153
|
+
description:
|
|
154
|
+
"Purge Varnish + Memcached + filesystem caches for the project's Cloudways app. Returns immediately — the actual purge is queued by Cloudways.",
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
properties: { projectId: { type: 'string' } },
|
|
158
|
+
required: ['projectId'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
// SEO / keywords
|
|
162
|
+
kura_list_tracked_keywords: {
|
|
163
|
+
name: 'kura_list_tracked_keywords',
|
|
164
|
+
description:
|
|
165
|
+
'List keywords being tracked for SERP rankings on a project. Returns each keyword + its latest rank position (if available).',
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: 'object',
|
|
168
|
+
properties: { projectId: { type: 'string' } },
|
|
169
|
+
required: ['projectId'],
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
} as const;
|
|
173
|
+
|
|
174
|
+
export type ToolName = keyof typeof TOOL_SCHEMAS;
|
|
175
|
+
|
|
176
|
+
// ---------- Tool dispatcher ----------
|
|
177
|
+
|
|
178
|
+
export async function callTool(
|
|
179
|
+
name: string,
|
|
180
|
+
args: unknown,
|
|
181
|
+
ctx: ToolContext,
|
|
182
|
+
): Promise<ToolResult> {
|
|
183
|
+
try {
|
|
184
|
+
switch (name) {
|
|
185
|
+
case 'kura_list_projects':
|
|
186
|
+
return await listProjects(args, ctx);
|
|
187
|
+
case 'kura_get_project':
|
|
188
|
+
return await getProject(args, ctx);
|
|
189
|
+
case 'kura_get_brand_kit':
|
|
190
|
+
return await getBrandKit(args, ctx);
|
|
191
|
+
case 'kura_update_brand_kit':
|
|
192
|
+
return await updateBrandKit(args, ctx);
|
|
193
|
+
case 'kura_list_pages':
|
|
194
|
+
return await listPages(args, ctx);
|
|
195
|
+
case 'kura_get_page':
|
|
196
|
+
return await getPage(args, ctx);
|
|
197
|
+
case 'kura_update_page':
|
|
198
|
+
return await updatePage(args, ctx);
|
|
199
|
+
case 'kura_trigger_deploy':
|
|
200
|
+
return await triggerDeploy(args, ctx);
|
|
201
|
+
case 'kura_list_media':
|
|
202
|
+
return await listMedia(args, ctx);
|
|
203
|
+
case 'kura_purge_cache':
|
|
204
|
+
return await purgeCache(args, ctx);
|
|
205
|
+
case 'kura_list_tracked_keywords':
|
|
206
|
+
return await listKeywords(args, ctx);
|
|
207
|
+
default:
|
|
208
|
+
return { ok: false, error: `unknown_tool:${name}` };
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
return { ok: false, error: `tool_threw:${(err as Error).message}` };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ============================================================
|
|
216
|
+
// Tool implementations
|
|
217
|
+
// ============================================================
|
|
218
|
+
|
|
219
|
+
async function listProjects(args: unknown, ctx: ToolContext): Promise<ToolResult> {
|
|
220
|
+
const includeArchived = readBoolArg(args, 'includeArchived', false);
|
|
221
|
+
const url = new URL(`${ctx.cfg.baseUrl}/api/projects`);
|
|
222
|
+
if (includeArchived) url.searchParams.set('includeArchived', 'true');
|
|
223
|
+
return apiGet(url.toString(), ctx);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function getProject(args: unknown, ctx: ToolContext): Promise<ToolResult> {
|
|
227
|
+
const projectId = readStringArg(args, 'projectId');
|
|
228
|
+
if (!projectId.ok) return projectId;
|
|
229
|
+
return apiGet(
|
|
230
|
+
`${ctx.cfg.baseUrl}/api/projects/by-id/${encodeURIComponent(projectId.value)}`,
|
|
231
|
+
ctx,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function getBrandKit(args: unknown, ctx: ToolContext): Promise<ToolResult> {
|
|
236
|
+
const projectId = readStringArg(args, 'projectId');
|
|
237
|
+
if (!projectId.ok) return projectId;
|
|
238
|
+
// The brand-kit GET endpoint accepts projectId via query param.
|
|
239
|
+
const url = new URL(`${ctx.cfg.baseUrl}/api/projects/brand-kit`);
|
|
240
|
+
url.searchParams.set('projectId', projectId.value);
|
|
241
|
+
return apiGet(url.toString(), ctx);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function updateBrandKit(args: unknown, ctx: ToolContext): Promise<ToolResult> {
|
|
245
|
+
const projectId = readStringArg(args, 'projectId');
|
|
246
|
+
if (!projectId.ok) return projectId;
|
|
247
|
+
const brandKit = readObjectArg(args, 'brandKit');
|
|
248
|
+
if (!brandKit.ok) return brandKit;
|
|
249
|
+
return apiSend('PUT', `${ctx.cfg.baseUrl}/api/projects/brand-kit`, ctx, {
|
|
250
|
+
projectId: projectId.value,
|
|
251
|
+
brandKit: brandKit.value,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function listPages(args: unknown, ctx: ToolContext): Promise<ToolResult> {
|
|
256
|
+
const projectId = readStringArg(args, 'projectId');
|
|
257
|
+
if (!projectId.ok) return projectId;
|
|
258
|
+
return apiGet(
|
|
259
|
+
`${ctx.cfg.baseUrl}/api/projects/${encodeURIComponent(projectId.value)}/pages`,
|
|
260
|
+
ctx,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function getPage(args: unknown, ctx: ToolContext): Promise<ToolResult> {
|
|
265
|
+
const projectId = readStringArg(args, 'projectId');
|
|
266
|
+
if (!projectId.ok) return projectId;
|
|
267
|
+
const slug = readStringArg(args, 'slug');
|
|
268
|
+
if (!slug.ok) return slug;
|
|
269
|
+
return apiGet(
|
|
270
|
+
`${ctx.cfg.baseUrl}/api/projects/${encodeURIComponent(projectId.value)}/pages/${encodeURIComponent(slug.value)}`,
|
|
271
|
+
ctx,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function updatePage(args: unknown, ctx: ToolContext): Promise<ToolResult> {
|
|
276
|
+
const projectId = readStringArg(args, 'projectId');
|
|
277
|
+
if (!projectId.ok) return projectId;
|
|
278
|
+
const slug = readStringArg(args, 'slug');
|
|
279
|
+
if (!slug.ok) return slug;
|
|
280
|
+
const content = readStringArg(args, 'content');
|
|
281
|
+
if (!content.ok) return content;
|
|
282
|
+
return apiSend(
|
|
283
|
+
'PUT',
|
|
284
|
+
`${ctx.cfg.baseUrl}/api/projects/${encodeURIComponent(projectId.value)}/pages/${encodeURIComponent(slug.value)}`,
|
|
285
|
+
ctx,
|
|
286
|
+
{ content: content.value },
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function triggerDeploy(args: unknown, ctx: ToolContext): Promise<ToolResult> {
|
|
291
|
+
const projectId = readStringArg(args, 'projectId');
|
|
292
|
+
if (!projectId.ok) return projectId;
|
|
293
|
+
const message = readOptionalStringArg(args, 'message');
|
|
294
|
+
return apiSend(
|
|
295
|
+
'POST',
|
|
296
|
+
`${ctx.cfg.baseUrl}/api/projects/${encodeURIComponent(projectId.value)}/publish`,
|
|
297
|
+
ctx,
|
|
298
|
+
message ? { message } : {},
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function listMedia(args: unknown, ctx: ToolContext): Promise<ToolResult> {
|
|
303
|
+
const projectId = readStringArg(args, 'projectId');
|
|
304
|
+
if (!projectId.ok) return projectId;
|
|
305
|
+
const url = new URL(`${ctx.cfg.baseUrl}/api/media`);
|
|
306
|
+
url.searchParams.set('projectId', projectId.value);
|
|
307
|
+
return apiGet(url.toString(), ctx);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function purgeCache(args: unknown, ctx: ToolContext): Promise<ToolResult> {
|
|
311
|
+
const projectId = readStringArg(args, 'projectId');
|
|
312
|
+
if (!projectId.ok) return projectId;
|
|
313
|
+
return apiSend(
|
|
314
|
+
'POST',
|
|
315
|
+
`${ctx.cfg.baseUrl}/api/projects/${encodeURIComponent(projectId.value)}/cache/purge`,
|
|
316
|
+
ctx,
|
|
317
|
+
{},
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function listKeywords(args: unknown, ctx: ToolContext): Promise<ToolResult> {
|
|
322
|
+
const projectId = readStringArg(args, 'projectId');
|
|
323
|
+
if (!projectId.ok) return projectId;
|
|
324
|
+
return apiGet(
|
|
325
|
+
`${ctx.cfg.baseUrl}/api/projects/${encodeURIComponent(projectId.value)}/keywords`,
|
|
326
|
+
ctx,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ============================================================
|
|
331
|
+
// HTTP helpers
|
|
332
|
+
// ============================================================
|
|
333
|
+
|
|
334
|
+
async function apiGet(url: string, ctx: ToolContext): Promise<ToolResult> {
|
|
335
|
+
const resp = await fetch(url, {
|
|
336
|
+
headers: { Authorization: `Bearer ${ctx.cfg.apiKey}` },
|
|
337
|
+
});
|
|
338
|
+
return parseFetchAsToolResult(resp);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function apiSend(
|
|
342
|
+
method: 'POST' | 'PUT' | 'DELETE' | 'PATCH',
|
|
343
|
+
url: string,
|
|
344
|
+
ctx: ToolContext,
|
|
345
|
+
body: unknown,
|
|
346
|
+
): Promise<ToolResult> {
|
|
347
|
+
const resp = await fetch(url, {
|
|
348
|
+
method,
|
|
349
|
+
headers: {
|
|
350
|
+
Authorization: `Bearer ${ctx.cfg.apiKey}`,
|
|
351
|
+
'content-type': 'application/json',
|
|
352
|
+
},
|
|
353
|
+
body: JSON.stringify(body),
|
|
354
|
+
});
|
|
355
|
+
return parseFetchAsToolResult(resp);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function parseFetchAsToolResult(resp: Response): Promise<ToolResult> {
|
|
359
|
+
const text = await resp.text();
|
|
360
|
+
let parsed: unknown;
|
|
361
|
+
try {
|
|
362
|
+
parsed = text ? JSON.parse(text) : null;
|
|
363
|
+
} catch {
|
|
364
|
+
parsed = { raw: text };
|
|
365
|
+
}
|
|
366
|
+
if (!resp.ok) {
|
|
367
|
+
return { ok: false, error: `http_${resp.status}`, data: parsed };
|
|
368
|
+
}
|
|
369
|
+
return { ok: true, data: parsed };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ============================================================
|
|
373
|
+
// Arg parsing helpers
|
|
374
|
+
// ============================================================
|
|
375
|
+
|
|
376
|
+
type ReadOk<T> = { ok: true; value: T };
|
|
377
|
+
type ReadErr = { ok: false; error: string };
|
|
378
|
+
|
|
379
|
+
function readStringArg(args: unknown, key: string): ReadOk<string> | ReadErr {
|
|
380
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args)) {
|
|
381
|
+
return { ok: false, error: 'missing_args_object' };
|
|
382
|
+
}
|
|
383
|
+
const v = (args as Record<string, unknown>)[key];
|
|
384
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
385
|
+
return { ok: false, error: `missing_or_empty_arg:${key}` };
|
|
386
|
+
}
|
|
387
|
+
return { ok: true, value: v };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function readOptionalStringArg(args: unknown, key: string): string | undefined {
|
|
391
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined;
|
|
392
|
+
const v = (args as Record<string, unknown>)[key];
|
|
393
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function readObjectArg(
|
|
397
|
+
args: unknown,
|
|
398
|
+
key: string,
|
|
399
|
+
): ReadOk<Record<string, unknown>> | ReadErr {
|
|
400
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args)) {
|
|
401
|
+
return { ok: false, error: 'missing_args_object' };
|
|
402
|
+
}
|
|
403
|
+
const v = (args as Record<string, unknown>)[key];
|
|
404
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) {
|
|
405
|
+
return { ok: false, error: `missing_or_invalid_arg:${key}` };
|
|
406
|
+
}
|
|
407
|
+
return { ok: true, value: v as Record<string, unknown> };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function readBoolArg(args: unknown, key: string, defaultValue: boolean): boolean {
|
|
411
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args)) return defaultValue;
|
|
412
|
+
const v = (args as Record<string, unknown>)[key];
|
|
413
|
+
return typeof v === 'boolean' ? v : defaultValue;
|
|
414
|
+
}
|