panini-connector-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/LICENSE +21 -0
- package/README.md +185 -0
- package/dist/index.cjs +728 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +704 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
// src/tools/install.ts
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
function buildInstall(input) {
|
|
10
|
+
const {
|
|
11
|
+
language,
|
|
12
|
+
framework,
|
|
13
|
+
storage,
|
|
14
|
+
existing_table = "blog_posts",
|
|
15
|
+
url_template = "https://yoursite.com/blog/{slug}"
|
|
16
|
+
} = input;
|
|
17
|
+
const envVars = envVarsFor(storage);
|
|
18
|
+
const verifyCommand = language === "python" ? `panini-connector test --url https://your-site.com/api/seo-connector --secret $SEO_CONNECTOR_SECRET` : `npx panini-connector test --url https://your-site.com/api/seo-connector --secret $SEO_CONNECTOR_SECRET`;
|
|
19
|
+
if (language === "node") {
|
|
20
|
+
return buildNode(framework, storage, existing_table, url_template, envVars, verifyCommand);
|
|
21
|
+
}
|
|
22
|
+
return buildPython(framework, storage, existing_table, url_template, envVars, verifyCommand);
|
|
23
|
+
}
|
|
24
|
+
function buildNode(framework, storage, table, urlTemplate, envVars, verifyCommand) {
|
|
25
|
+
const isCustomDb = storage === "custom_db";
|
|
26
|
+
const peerDeps = [
|
|
27
|
+
storage === "postgres" ? "pg" : null,
|
|
28
|
+
isCustomDb ? "mysql2 # or mongodb / mssql / your DB driver" : null,
|
|
29
|
+
framework === "express" ? "express" : null
|
|
30
|
+
].filter(Boolean);
|
|
31
|
+
const install = `npm install panini-connector` + (peerDeps.length ? `
|
|
32
|
+
npm install ${peerDeps.join(" ")}` : "");
|
|
33
|
+
const storageImport = storage === "supabase" ? "import { SupabaseStorage } from 'panini-connector/storage/supabase'" : storage === "postgres" ? "import { PostgresStorage } from 'panini-connector/storage/postgres'" : isCustomDb ? `import type { StorageAdapter, Post } from 'panini-connector'
|
|
34
|
+
import mysql from 'mysql2/promise' // \u2190 swap for your DB driver` : "import { MemoryStorage } from 'panini-connector'";
|
|
35
|
+
const storageInstance = isCustomDb ? "new MyStorage()" : storage === "supabase" || storage === "postgres" ? renderExistingTableInstance(storage, table, urlTemplate) : "new MemoryStorage()";
|
|
36
|
+
const customDbClass = isCustomDb ? renderCustomDbClass(table, urlTemplate) : "";
|
|
37
|
+
const nextAppCode = `import { createHandler } from 'panini-connector/next-app'
|
|
38
|
+
${storageImport}
|
|
39
|
+
${customDbClass}
|
|
40
|
+
const handler = createHandler({
|
|
41
|
+
storage: ${storageInstance},
|
|
42
|
+
})
|
|
43
|
+
export const POST = handler`;
|
|
44
|
+
const nextPagesCode = `import { createHandler } from 'panini-connector/next-pages'
|
|
45
|
+
${storageImport}
|
|
46
|
+
${customDbClass}
|
|
47
|
+
export const config = { api: { bodyParser: false } }
|
|
48
|
+
|
|
49
|
+
export default createHandler({
|
|
50
|
+
storage: ${storageInstance},
|
|
51
|
+
})`;
|
|
52
|
+
const expressCode = `import express from 'express'
|
|
53
|
+
import { mount } from 'panini-connector/express'
|
|
54
|
+
${storageImport}
|
|
55
|
+
${customDbClass}
|
|
56
|
+
const app = express()
|
|
57
|
+
|
|
58
|
+
mount(app, {
|
|
59
|
+
path: '/api/seo-connector',
|
|
60
|
+
storage: ${storageInstance},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
app.use(express.json()) // Safe here \u2014 comes AFTER mount()
|
|
64
|
+
app.listen(3000)`;
|
|
65
|
+
const filePath = framework === "next_app" ? "app/api/seo-connector/route.ts" : framework === "next_pages" ? "pages/api/seo-connector.ts" : "server.ts";
|
|
66
|
+
const code = framework === "next_app" ? nextAppCode : framework === "next_pages" ? nextPagesCode : expressCode;
|
|
67
|
+
const postNote = buildPostNote(framework, storage);
|
|
68
|
+
return {
|
|
69
|
+
install_command: install,
|
|
70
|
+
files: [{ path: filePath, content: code, language: "typescript" }],
|
|
71
|
+
env_vars: envVars,
|
|
72
|
+
post_install_note: postNote,
|
|
73
|
+
verify_command: verifyCommand
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function renderExistingTableInstance(storage, table, urlTemplate) {
|
|
77
|
+
const cls = storage === "supabase" ? "SupabaseStorage" : "PostgresStorage";
|
|
78
|
+
return `new ${cls}({
|
|
79
|
+
table: '${table}',
|
|
80
|
+
externalIdColumn: 'id',
|
|
81
|
+
externalUrlTemplate: '${urlTemplate}',
|
|
82
|
+
toRow: (post) => ({
|
|
83
|
+
title: post.title,
|
|
84
|
+
slug: post.slug,
|
|
85
|
+
content: post.body_html,
|
|
86
|
+
description: post.excerpt || post.seo_meta?.description || '',
|
|
87
|
+
meta_title: post.seo_meta?.title || post.title,
|
|
88
|
+
meta_description: post.seo_meta?.description || post.excerpt,
|
|
89
|
+
featured_image_url: post.featured_image_url,
|
|
90
|
+
author_name: post.author_name,
|
|
91
|
+
status: post.status || 'published',
|
|
92
|
+
category: 'seo',
|
|
93
|
+
external_source: 'panini',
|
|
94
|
+
published_at: new Date().toISOString(),
|
|
95
|
+
}),
|
|
96
|
+
})`;
|
|
97
|
+
}
|
|
98
|
+
function renderCustomDbClass(table, urlTemplate) {
|
|
99
|
+
const slugTemplate = urlTemplate.replace("{slug}", "${post.slug}");
|
|
100
|
+
return `
|
|
101
|
+
class MyStorage implements StorageAdapter {
|
|
102
|
+
async ping() {
|
|
103
|
+
return { site_name: '${table}', version: '1.0' }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async create_post(post: Post) {
|
|
107
|
+
const conn = await mysql.createConnection(process.env.DATABASE_URL!)
|
|
108
|
+
const [result] = await conn.execute(
|
|
109
|
+
\`INSERT INTO ${table}
|
|
110
|
+
(title, slug, content, description, status, external_source)
|
|
111
|
+
VALUES (?, ?, ?, ?, ?, ?)\`,
|
|
112
|
+
[post.title, post.slug, post.body_html,
|
|
113
|
+
post.excerpt || post.seo_meta?.description || '',
|
|
114
|
+
'published', 'panini'],
|
|
115
|
+
)
|
|
116
|
+
await conn.end()
|
|
117
|
+
const externalId = String((result as { insertId: number }).insertId)
|
|
118
|
+
return {
|
|
119
|
+
external_id: externalId,
|
|
120
|
+
external_url: \`${slugTemplate}\`,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async update_post(externalId: string, post: Post) {
|
|
125
|
+
const conn = await mysql.createConnection(process.env.DATABASE_URL!)
|
|
126
|
+
await conn.execute(
|
|
127
|
+
\`UPDATE ${table} SET title=?, content=?, description=? WHERE id=?\`,
|
|
128
|
+
[post.title, post.body_html, post.excerpt || '', externalId],
|
|
129
|
+
)
|
|
130
|
+
await conn.end()
|
|
131
|
+
return { external_id: externalId, external_url: \`${slugTemplate}\` }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async delete_post(externalId: string) {
|
|
135
|
+
const conn = await mysql.createConnection(process.env.DATABASE_URL!)
|
|
136
|
+
await conn.execute(\`DELETE FROM ${table} WHERE id=?\`, [externalId])
|
|
137
|
+
await conn.end()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
`;
|
|
141
|
+
}
|
|
142
|
+
function buildPostNote(framework, storage) {
|
|
143
|
+
const parts = [];
|
|
144
|
+
if (storage === "custom_db") {
|
|
145
|
+
parts.push(
|
|
146
|
+
"Fill in the 4 methods of MyStorage with your DB queries \u2014 swap mysql2 for your driver (mongodb, mssql, cosmos, etc.)."
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (framework === "next_pages") {
|
|
150
|
+
parts.push(
|
|
151
|
+
"You MUST keep the `export const config = { api: { bodyParser: false } }` line \u2014 Next's default JSON parser would consume the raw bytes we need for HMAC verification."
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (framework === "express") {
|
|
155
|
+
parts.push(
|
|
156
|
+
"If you already have `express.json()` mounted globally, move it BELOW the `mount()` call so the SDK can read raw request bytes."
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return parts.length ? parts.join(" ") : void 0;
|
|
160
|
+
}
|
|
161
|
+
function buildPython(framework, storage, table, urlTemplate, envVars, verifyCommand) {
|
|
162
|
+
const isCustomDb = storage === "custom_db";
|
|
163
|
+
const storageExtra = storage === "memory" || isCustomDb ? "" : `,${storage}`;
|
|
164
|
+
const install = `pip install "panini-connector[${framework}${storageExtra}] @ git+https://github.com/prak16/PaniniOS.git@main#subdirectory=sdk/panini-connector-py"` + (isCustomDb ? "\npip install pymysql # or motor / pymongo / pymssql" : "");
|
|
165
|
+
const storageArg = isCustomDb ? "storage=MyStorage()" : storage === "supabase" || storage === "postgres" ? renderPyExistingTable(storage, table, urlTemplate) : `storage="${storage}"`;
|
|
166
|
+
const customDbClass = isCustomDb ? renderPyCustomDbClass(table, urlTemplate) : "";
|
|
167
|
+
const extraImport = isCustomDb ? "" : storage === "supabase" || storage === "postgres" ? `
|
|
168
|
+
from panini_connector.storage.${storage} import ${storage === "supabase" ? "SupabaseStorage" : "PostgresStorage"}
|
|
169
|
+
` : "\n";
|
|
170
|
+
let filePath = "main.py";
|
|
171
|
+
let code = "";
|
|
172
|
+
if (framework === "fastapi") {
|
|
173
|
+
filePath = "main.py";
|
|
174
|
+
code = `from fastapi import FastAPI
|
|
175
|
+
from panini_connector import mount${extraImport}${customDbClass}
|
|
176
|
+
app = FastAPI()
|
|
177
|
+
mount(
|
|
178
|
+
app,
|
|
179
|
+
path="/api/seo-connector",
|
|
180
|
+
${storageArg},
|
|
181
|
+
)`;
|
|
182
|
+
} else if (framework === "django") {
|
|
183
|
+
filePath = "urls.py";
|
|
184
|
+
code = `from django.urls import path
|
|
185
|
+
from panini_connector.framework.django import view${extraImport}${customDbClass}
|
|
186
|
+
urlpatterns = [
|
|
187
|
+
# ... your existing routes ...
|
|
188
|
+
path(
|
|
189
|
+
"api/seo-connector",
|
|
190
|
+
view(${storageArg}),
|
|
191
|
+
name="panini_connector",
|
|
192
|
+
),
|
|
193
|
+
]`;
|
|
194
|
+
} else {
|
|
195
|
+
filePath = "app.py";
|
|
196
|
+
code = `from flask import Flask
|
|
197
|
+
from panini_connector import mount_flask${extraImport}${customDbClass}
|
|
198
|
+
app = Flask(__name__)
|
|
199
|
+
mount_flask(
|
|
200
|
+
app,
|
|
201
|
+
path="/api/seo-connector",
|
|
202
|
+
${storageArg},
|
|
203
|
+
)`;
|
|
204
|
+
}
|
|
205
|
+
const parts = [];
|
|
206
|
+
if (isCustomDb) {
|
|
207
|
+
parts.push(
|
|
208
|
+
"Fill in the 4 methods of MyStorage with your DB queries \u2014 swap pymysql for your driver (motor / pymongo / pymssql / etc.)."
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (framework === "django") {
|
|
212
|
+
parts.push("Requires Django 4.1+ for async views. CSRF exemption is handled by the SDK.");
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
install_command: install,
|
|
216
|
+
files: [{ path: filePath, content: code, language: "python" }],
|
|
217
|
+
env_vars: envVars,
|
|
218
|
+
post_install_note: parts.length ? parts.join(" ") : void 0,
|
|
219
|
+
verify_command: verifyCommand
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function renderPyExistingTable(storage, table, urlTemplate) {
|
|
223
|
+
const cls = storage === "supabase" ? "SupabaseStorage" : "PostgresStorage";
|
|
224
|
+
return `storage=${cls}(
|
|
225
|
+
table="${table}",
|
|
226
|
+
external_id_column="id",
|
|
227
|
+
external_url_template="${urlTemplate}",
|
|
228
|
+
to_row=lambda post: {
|
|
229
|
+
"title": post["title"],
|
|
230
|
+
"slug": post["slug"],
|
|
231
|
+
"content": post["body_html"],
|
|
232
|
+
"description": post.get("excerpt") or post.get("seo_meta", {}).get("description", ""),
|
|
233
|
+
"meta_title": post.get("seo_meta", {}).get("title") or post["title"],
|
|
234
|
+
"meta_description": post.get("seo_meta", {}).get("description") or post.get("excerpt", ""),
|
|
235
|
+
"featured_image_url": post.get("featured_image_url"),
|
|
236
|
+
"author_name": post.get("author_name"),
|
|
237
|
+
"status": post.get("status", "published"),
|
|
238
|
+
"category": "seo",
|
|
239
|
+
"external_source": "panini",
|
|
240
|
+
},
|
|
241
|
+
)`;
|
|
242
|
+
}
|
|
243
|
+
function renderPyCustomDbClass(table, urlTemplate) {
|
|
244
|
+
const slugTemplate = urlTemplate.replace("{slug}", '{post["slug"]}');
|
|
245
|
+
return `
|
|
246
|
+
import os
|
|
247
|
+
import pymysql
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class MyStorage:
|
|
251
|
+
async def ping(self):
|
|
252
|
+
return {"site_name": "${table}", "version": "1.0"}
|
|
253
|
+
|
|
254
|
+
async def create_post(self, post):
|
|
255
|
+
conn = pymysql.connect(host="localhost", user="user", password="pass", db="dbname")
|
|
256
|
+
with conn.cursor() as cur:
|
|
257
|
+
cur.execute(
|
|
258
|
+
"""INSERT INTO ${table}
|
|
259
|
+
(title, slug, content, description, status, external_source)
|
|
260
|
+
VALUES (%s, %s, %s, %s, %s, %s)""",
|
|
261
|
+
(post["title"], post["slug"], post["body_html"],
|
|
262
|
+
post.get("excerpt", ""), "published", "panini"),
|
|
263
|
+
)
|
|
264
|
+
external_id = cur.lastrowid
|
|
265
|
+
conn.commit(); conn.close()
|
|
266
|
+
return {"external_id": str(external_id), "external_url": f"${slugTemplate}"}
|
|
267
|
+
|
|
268
|
+
async def update_post(self, external_id, post):
|
|
269
|
+
conn = pymysql.connect(host="localhost", user="user", password="pass", db="dbname")
|
|
270
|
+
with conn.cursor() as cur:
|
|
271
|
+
cur.execute(
|
|
272
|
+
"""UPDATE ${table} SET title=%s, content=%s, description=%s WHERE id=%s""",
|
|
273
|
+
(post["title"], post["body_html"], post.get("excerpt", ""), external_id),
|
|
274
|
+
)
|
|
275
|
+
conn.commit(); conn.close()
|
|
276
|
+
return {"external_id": external_id, "external_url": f"${slugTemplate}"}
|
|
277
|
+
|
|
278
|
+
async def delete_post(self, external_id):
|
|
279
|
+
conn = pymysql.connect(host="localhost", user="user", password="pass", db="dbname")
|
|
280
|
+
with conn.cursor() as cur:
|
|
281
|
+
cur.execute("DELETE FROM ${table} WHERE id=%s", (external_id,))
|
|
282
|
+
conn.commit(); conn.close()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
`;
|
|
286
|
+
}
|
|
287
|
+
function envVarsFor(storage) {
|
|
288
|
+
const base = "SEO_CONNECTOR_SECRET=<call generate_shared_secret to get one>";
|
|
289
|
+
if (storage === "supabase") {
|
|
290
|
+
return `${base}
|
|
291
|
+
SUPABASE_URL=https://xxxxx.supabase.co
|
|
292
|
+
SUPABASE_SERVICE_ROLE_KEY=<service_role key, NOT anon>`;
|
|
293
|
+
}
|
|
294
|
+
if (storage === "postgres") {
|
|
295
|
+
return `${base}
|
|
296
|
+
DATABASE_URL=postgresql://user:pass@host:5432/dbname`;
|
|
297
|
+
}
|
|
298
|
+
if (storage === "custom_db") {
|
|
299
|
+
return `${base}
|
|
300
|
+
# Whatever env vars YOUR DB driver needs \u2014 e.g. for MySQL:
|
|
301
|
+
DATABASE_URL=mysql://user:pass@host:3306/dbname`;
|
|
302
|
+
}
|
|
303
|
+
return base + "\n# (in-memory needs no extra env vars \u2014 posts vanish on restart)";
|
|
304
|
+
}
|
|
305
|
+
function registerInstallTool(server) {
|
|
306
|
+
server.registerTool(
|
|
307
|
+
"install_panini_connector",
|
|
308
|
+
{
|
|
309
|
+
title: "Install panini-connector on a site",
|
|
310
|
+
description: "Given the customer's stack (language + framework + storage), returns the exact commands, code, and env vars to wire panini-connector into their self-hosted site. The AI agent should: (1) call this tool, (2) run the install_command in the project root, (3) write each file to the path shown, (4) prompt the user to set the env_vars on their hosting platform, (5) tell them to redeploy, then (6) call verify_panini_connector to confirm the setup works end-to-end.",
|
|
311
|
+
inputSchema: {
|
|
312
|
+
language: z.enum(["node", "python"]).describe("The customer site's runtime language."),
|
|
313
|
+
framework: z.enum(["next_app", "next_pages", "express", "fastapi", "django", "flask"]).describe(
|
|
314
|
+
"The customer's web framework. next_app = Next.js App Router, next_pages = Next.js Pages Router. Must match `language`: node \u2192 next_app|next_pages|express; python \u2192 fastapi|django|flask."
|
|
315
|
+
),
|
|
316
|
+
storage: z.enum(["supabase", "postgres", "custom_db", "memory"]).describe(
|
|
317
|
+
"Where posts land. supabase = Supabase via PostgREST. postgres = direct Postgres DSN. custom_db = MySQL/Mongo/MSSQL/CosmosDB/CMS \u2014 SDK generates a StorageAdapter class scaffold. memory = ephemeral in-memory (testing only, posts vanish on restart)."
|
|
318
|
+
),
|
|
319
|
+
existing_table: z.string().optional().describe(
|
|
320
|
+
`Name of the customer's existing blog/posts table (e.g. "blog_posts"). For supabase/postgres, seeds the toRow mapping. For custom_db, seeds the SQL. Defaults to "blog_posts".`
|
|
321
|
+
),
|
|
322
|
+
url_template: z.string().optional().describe(
|
|
323
|
+
'URL where published posts appear on the customer site. Use {slug} and/or {id} placeholders. Example: "https://mysite.com/blog/{slug}". Defaults to "https://yoursite.com/blog/{slug}".'
|
|
324
|
+
)
|
|
325
|
+
},
|
|
326
|
+
outputSchema: {
|
|
327
|
+
install_command: z.string(),
|
|
328
|
+
files: z.array(
|
|
329
|
+
z.object({
|
|
330
|
+
path: z.string(),
|
|
331
|
+
content: z.string(),
|
|
332
|
+
language: z.string()
|
|
333
|
+
})
|
|
334
|
+
),
|
|
335
|
+
env_vars: z.string(),
|
|
336
|
+
post_install_note: z.string().optional(),
|
|
337
|
+
verify_command: z.string()
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
async (args) => {
|
|
341
|
+
const result = buildInstall(args);
|
|
342
|
+
return {
|
|
343
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
344
|
+
structuredContent: result
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/tools/verify.ts
|
|
351
|
+
import { createHmac } from "crypto";
|
|
352
|
+
import { z as z2 } from "zod";
|
|
353
|
+
var VERSION = "1";
|
|
354
|
+
function computeSignature(secret, timestamp, rawBody) {
|
|
355
|
+
return "sha256=" + createHmac("sha256", secret).update(timestamp + "." + rawBody, "utf-8").digest("hex");
|
|
356
|
+
}
|
|
357
|
+
async function fireOp(url, secret, body, timeoutMs) {
|
|
358
|
+
const raw = JSON.stringify(body);
|
|
359
|
+
const ts = String(Math.floor(Date.now() / 1e3));
|
|
360
|
+
const sig = computeSignature(secret, ts, raw);
|
|
361
|
+
const controller = new AbortController();
|
|
362
|
+
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
|
|
363
|
+
try {
|
|
364
|
+
const r = await fetch(url, {
|
|
365
|
+
method: "POST",
|
|
366
|
+
headers: {
|
|
367
|
+
"Content-Type": "application/json",
|
|
368
|
+
"X-Compass-Timestamp": ts,
|
|
369
|
+
"X-Compass-Signature": sig,
|
|
370
|
+
"X-Compass-Version": VERSION
|
|
371
|
+
},
|
|
372
|
+
body: raw,
|
|
373
|
+
signal: controller.signal
|
|
374
|
+
});
|
|
375
|
+
clearTimeout(timeoutHandle);
|
|
376
|
+
let parsed;
|
|
377
|
+
const text = await r.text();
|
|
378
|
+
try {
|
|
379
|
+
parsed = JSON.parse(text);
|
|
380
|
+
} catch {
|
|
381
|
+
parsed = text.slice(0, 500);
|
|
382
|
+
}
|
|
383
|
+
return { httpStatus: r.status, body: parsed };
|
|
384
|
+
} catch (e) {
|
|
385
|
+
clearTimeout(timeoutHandle);
|
|
386
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
387
|
+
return { httpStatus: 0, body: null, error: msg };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function newRequestId() {
|
|
391
|
+
return "mcp-verify-" + Date.now().toString(16) + "-" + Math.floor(Math.random() * 1e6).toString(16);
|
|
392
|
+
}
|
|
393
|
+
async function runVerify(input) {
|
|
394
|
+
const url = input.url;
|
|
395
|
+
const secret = input.secret;
|
|
396
|
+
const timeoutMs = input.timeout_ms ?? 1e4;
|
|
397
|
+
const ops = [];
|
|
398
|
+
let externalId;
|
|
399
|
+
const slug = `panini-mcp-smoke-${Date.now().toString(36)}`;
|
|
400
|
+
{
|
|
401
|
+
const req = { op: "ping", version: VERSION, request_id: newRequestId() };
|
|
402
|
+
const r = await fireOp(url, secret, req, timeoutMs);
|
|
403
|
+
const passed = r.httpStatus === 200;
|
|
404
|
+
ops.push({
|
|
405
|
+
op: "ping",
|
|
406
|
+
status: passed ? "pass" : "fail",
|
|
407
|
+
http_status: r.httpStatus,
|
|
408
|
+
response_body: r.body,
|
|
409
|
+
error: r.error
|
|
410
|
+
});
|
|
411
|
+
if (!passed) {
|
|
412
|
+
return summarize(url, ops);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
{
|
|
416
|
+
const req = {
|
|
417
|
+
op: "create_post",
|
|
418
|
+
version: VERSION,
|
|
419
|
+
request_id: newRequestId(),
|
|
420
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
421
|
+
post: {
|
|
422
|
+
title: "MCP verify smoke test",
|
|
423
|
+
slug,
|
|
424
|
+
body_html: "<p>Auto-generated by verify_panini_connector. Safe to delete.</p>",
|
|
425
|
+
excerpt: "MCP smoke test",
|
|
426
|
+
categories: [],
|
|
427
|
+
tags: [],
|
|
428
|
+
featured_image_url: "",
|
|
429
|
+
author_name: "panini-connector-mcp",
|
|
430
|
+
seo_meta: { title: "MCP smoke", description: "MCP smoke" },
|
|
431
|
+
json_ld: [],
|
|
432
|
+
status: "draft"
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const r = await fireOp(url, secret, req, timeoutMs);
|
|
436
|
+
const passed = r.httpStatus === 200;
|
|
437
|
+
const body = r.body;
|
|
438
|
+
if (passed && body?.external_id) externalId = body.external_id;
|
|
439
|
+
ops.push({
|
|
440
|
+
op: "create_post",
|
|
441
|
+
status: passed ? "pass" : "fail",
|
|
442
|
+
http_status: r.httpStatus,
|
|
443
|
+
response_body: r.body,
|
|
444
|
+
error: r.error
|
|
445
|
+
});
|
|
446
|
+
if (!passed || !externalId) return summarize(url, ops);
|
|
447
|
+
}
|
|
448
|
+
{
|
|
449
|
+
const req = {
|
|
450
|
+
op: "update_post",
|
|
451
|
+
version: VERSION,
|
|
452
|
+
request_id: newRequestId(),
|
|
453
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
454
|
+
external_id: externalId,
|
|
455
|
+
post: {
|
|
456
|
+
title: "MCP verify smoke test (updated)",
|
|
457
|
+
slug,
|
|
458
|
+
body_html: "<p>Updated. Safe to delete.</p>",
|
|
459
|
+
excerpt: "updated",
|
|
460
|
+
categories: [],
|
|
461
|
+
tags: [],
|
|
462
|
+
featured_image_url: "",
|
|
463
|
+
author_name: "panini-connector-mcp",
|
|
464
|
+
seo_meta: {},
|
|
465
|
+
json_ld: [],
|
|
466
|
+
status: "draft"
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
const r = await fireOp(url, secret, req, timeoutMs);
|
|
470
|
+
ops.push({
|
|
471
|
+
op: "update_post",
|
|
472
|
+
status: r.httpStatus === 200 ? "pass" : "fail",
|
|
473
|
+
http_status: r.httpStatus,
|
|
474
|
+
response_body: r.body,
|
|
475
|
+
error: r.error
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
{
|
|
479
|
+
const req = {
|
|
480
|
+
op: "delete_post",
|
|
481
|
+
version: VERSION,
|
|
482
|
+
request_id: newRequestId(),
|
|
483
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
484
|
+
external_id: externalId,
|
|
485
|
+
post: {}
|
|
486
|
+
};
|
|
487
|
+
const r = await fireOp(url, secret, req, timeoutMs);
|
|
488
|
+
ops.push({
|
|
489
|
+
op: "delete_post",
|
|
490
|
+
status: r.httpStatus === 200 ? "pass" : "fail",
|
|
491
|
+
http_status: r.httpStatus,
|
|
492
|
+
response_body: r.body,
|
|
493
|
+
error: r.error
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
return summarize(url, ops);
|
|
497
|
+
}
|
|
498
|
+
function summarize(url, ops) {
|
|
499
|
+
const passes = ops.filter((o) => o.status === "pass").length;
|
|
500
|
+
const fails = ops.length - passes;
|
|
501
|
+
const ok = fails === 0 && ops.length === 4;
|
|
502
|
+
const summary = ok ? `\u2713 All 4 ops passed against ${url}. Connector is wired correctly.` : `\u2717 ${fails}/${ops.length} ops failed against ${url}. See per-op detail.`;
|
|
503
|
+
return { ok, url, ops, summary };
|
|
504
|
+
}
|
|
505
|
+
function registerVerifyTool(server) {
|
|
506
|
+
server.registerTool(
|
|
507
|
+
"verify_panini_connector",
|
|
508
|
+
{
|
|
509
|
+
title: "Verify a deployed panini-connector endpoint",
|
|
510
|
+
description: "Fire all 4 HMAC-signed operations (ping, create_post, update_post, delete_post) at the customer's deployed connector URL. Returns per-op status + diagnostics. Call this AFTER install_panini_connector's code has been written + env vars set + site redeployed, to confirm the round-trip works. If any op fails, the response_body will contain the customer's handler error text \u2014 enough to debug (wrong secret? missing table column? RLS policy? etc.). Cleans up after itself by deleting the smoke-test post.",
|
|
511
|
+
inputSchema: {
|
|
512
|
+
url: z2.string().url().describe(
|
|
513
|
+
'The full HTTPS URL of the deployed connector endpoint. Must end with the mount path (usually "/api/seo-connector"). Example: "https://mysite.com/api/seo-connector"'
|
|
514
|
+
),
|
|
515
|
+
secret: z2.string().min(8).describe(
|
|
516
|
+
"The SEO_CONNECTOR_SECRET value set as an env var on the customer site. Must match byte-for-byte or all signatures will 401."
|
|
517
|
+
),
|
|
518
|
+
timeout_ms: z2.number().int().min(1e3).max(6e4).optional().describe("Per-op HTTP timeout in ms. Default 10000.")
|
|
519
|
+
},
|
|
520
|
+
outputSchema: {
|
|
521
|
+
ok: z2.boolean(),
|
|
522
|
+
url: z2.string(),
|
|
523
|
+
ops: z2.array(
|
|
524
|
+
z2.object({
|
|
525
|
+
op: z2.enum(["ping", "create_post", "update_post", "delete_post"]),
|
|
526
|
+
status: z2.enum(["pass", "fail"]),
|
|
527
|
+
http_status: z2.number().optional(),
|
|
528
|
+
response_body: z2.any().optional(),
|
|
529
|
+
error: z2.string().optional()
|
|
530
|
+
})
|
|
531
|
+
),
|
|
532
|
+
summary: z2.string()
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
async (args) => {
|
|
536
|
+
const result = await runVerify(args);
|
|
537
|
+
return {
|
|
538
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
539
|
+
structuredContent: result
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// src/tools/secret.ts
|
|
546
|
+
import { randomBytes } from "crypto";
|
|
547
|
+
import { z as z3 } from "zod";
|
|
548
|
+
function generateSharedSecret() {
|
|
549
|
+
const secret = randomBytes(32).toString("hex");
|
|
550
|
+
return { secret, length_bytes: 32 };
|
|
551
|
+
}
|
|
552
|
+
function registerSecretTool(server) {
|
|
553
|
+
server.registerTool(
|
|
554
|
+
"generate_shared_secret",
|
|
555
|
+
{
|
|
556
|
+
title: "Generate shared secret",
|
|
557
|
+
description: 'Generate a cryptographically-random 32-byte hex secret. Use this as the SEO_CONNECTOR_SECRET env var on the customer site AND paste the same value into the Panini dashboard "Shared secret" field \u2014 both sides must match for HMAC signatures to verify.',
|
|
558
|
+
inputSchema: {},
|
|
559
|
+
outputSchema: {
|
|
560
|
+
secret: z3.string().describe("64-character hex string (32 raw bytes)"),
|
|
561
|
+
length_bytes: z3.number().describe("Number of raw bytes before hex-encoding")
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
async () => {
|
|
565
|
+
const result = generateSharedSecret();
|
|
566
|
+
return {
|
|
567
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
568
|
+
structuredContent: result
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// src/tools/schema.ts
|
|
575
|
+
import { z as z4 } from "zod";
|
|
576
|
+
var ENVELOPE_CONTRACT = {
|
|
577
|
+
http_method: "POST",
|
|
578
|
+
request_headers: {
|
|
579
|
+
"Content-Type": "application/json",
|
|
580
|
+
"X-Compass-Timestamp": "<unix seconds, string>",
|
|
581
|
+
"X-Compass-Signature": "sha256=<64-hex-chars, HMAC-SHA256 over `${timestamp}.${raw_body}` with SEO_CONNECTOR_SECRET>",
|
|
582
|
+
"X-Compass-Version": "1"
|
|
583
|
+
},
|
|
584
|
+
request_body_shape: {
|
|
585
|
+
op: "create_post | update_post | delete_post | ping",
|
|
586
|
+
version: "1",
|
|
587
|
+
request_id: "<uuid>",
|
|
588
|
+
timestamp: "<ISO-8601 datetime>",
|
|
589
|
+
post: {
|
|
590
|
+
title: "string",
|
|
591
|
+
slug: "string (URL-safe)",
|
|
592
|
+
body_html: "string (rendered HTML)",
|
|
593
|
+
excerpt: "string (short summary)",
|
|
594
|
+
categories: "[string]",
|
|
595
|
+
tags: "[string]",
|
|
596
|
+
featured_image_url: "string (absolute URL) | null",
|
|
597
|
+
author_name: "string | null",
|
|
598
|
+
seo_meta: { title: "string", description: "string" },
|
|
599
|
+
json_ld: "[object] (Schema.org structured data)",
|
|
600
|
+
status: "publish | draft"
|
|
601
|
+
},
|
|
602
|
+
external_id: "<string, only for update_post + delete_post>"
|
|
603
|
+
},
|
|
604
|
+
response_success: {
|
|
605
|
+
http_status: 200,
|
|
606
|
+
body: {
|
|
607
|
+
ok: true,
|
|
608
|
+
external_id: "<your DB primary key for this post>",
|
|
609
|
+
external_url: "<publicly-viewable URL, e.g. https://mysite.com/blog/slug>"
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
response_ping: {
|
|
613
|
+
http_status: 200,
|
|
614
|
+
body: {
|
|
615
|
+
ok: true,
|
|
616
|
+
site_name: "<display name>",
|
|
617
|
+
version: "1",
|
|
618
|
+
supported_ops: ["ping", "create_post", "update_post", "delete_post"]
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
response_errors: {
|
|
622
|
+
signature_fail: { http_status: 401, body: { ok: false, error: "signature verification failed" } },
|
|
623
|
+
bad_envelope: { http_status: 400, body: { ok: false, error: "<parse error detail>" } },
|
|
624
|
+
unknown_post: { http_status: 404, body: { ok: false, error: "no post with external_id=<id>" } },
|
|
625
|
+
internal_error: { http_status: 500, body: { ok: false, error: "<internal>" } }
|
|
626
|
+
},
|
|
627
|
+
replay_window_seconds: 300,
|
|
628
|
+
hmac_pseudocode: 'expected = "sha256=" + HMAC_SHA256(SEO_CONNECTOR_SECRET, timestamp + "." + raw_body).hex_lower()\nif not constant_time_equal(expected, X-Compass-Signature): reject 401\nif abs(now_unix_seconds - int(timestamp)) > 300: reject 401',
|
|
629
|
+
laravel_example: `// PHP / Laravel \u2014 verify signature in a controller method
|
|
630
|
+
$ts = $request->header('X-Compass-Timestamp');
|
|
631
|
+
$sig = $request->header('X-Compass-Signature');
|
|
632
|
+
$body = $request->getContent(); // raw bytes, before any JSON parsing
|
|
633
|
+
$expect = 'sha256=' . hash_hmac('sha256', "$ts.$body", env('SEO_CONNECTOR_SECRET'));
|
|
634
|
+
if (!hash_equals($expect, $sig)) return response()->json(['ok' => false], 401);
|
|
635
|
+
if (abs(time() - (int)$ts) > 300) return response()->json(['ok' => false], 401);
|
|
636
|
+
$payload = json_decode($body, true);
|
|
637
|
+
// ... route on $payload['op'], write to your DB, respond {ok, external_id, external_url}`
|
|
638
|
+
};
|
|
639
|
+
function getEnvelopeSchema() {
|
|
640
|
+
return ENVELOPE_CONTRACT;
|
|
641
|
+
}
|
|
642
|
+
function registerSchemaTool(server) {
|
|
643
|
+
server.registerTool(
|
|
644
|
+
"get_envelope_schema",
|
|
645
|
+
{
|
|
646
|
+
title: "Get envelope schema",
|
|
647
|
+
description: "Return the raw HTTP envelope contract Panini uses to POST to customer sites. Use this when the customer stack is NOT Node or Python (e.g. Laravel, Rails, Go, .NET, Rust) and you need to hand-implement the connector in their language. Includes request/response shapes, HMAC verification pseudocode, and a Laravel example.",
|
|
648
|
+
inputSchema: {},
|
|
649
|
+
outputSchema: {
|
|
650
|
+
http_method: z4.string(),
|
|
651
|
+
request_headers: z4.record(z4.string()),
|
|
652
|
+
request_body_shape: z4.record(z4.any()),
|
|
653
|
+
response_success: z4.record(z4.any()),
|
|
654
|
+
response_ping: z4.record(z4.any()),
|
|
655
|
+
response_errors: z4.record(z4.any()),
|
|
656
|
+
replay_window_seconds: z4.number(),
|
|
657
|
+
hmac_pseudocode: z4.string(),
|
|
658
|
+
laravel_example: z4.string()
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
async () => {
|
|
662
|
+
const result = getEnvelopeSchema();
|
|
663
|
+
return {
|
|
664
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
665
|
+
structuredContent: result
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// src/index.ts
|
|
672
|
+
function log(message) {
|
|
673
|
+
process.stderr.write(`[panini-connector-mcp] ${message}
|
|
674
|
+
`);
|
|
675
|
+
}
|
|
676
|
+
async function main() {
|
|
677
|
+
const server = new McpServer(
|
|
678
|
+
{
|
|
679
|
+
name: "panini-connector-mcp",
|
|
680
|
+
version: "0.1.0"
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
capabilities: {
|
|
684
|
+
tools: {}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
);
|
|
688
|
+
registerInstallTool(server);
|
|
689
|
+
registerVerifyTool(server);
|
|
690
|
+
registerSecretTool(server);
|
|
691
|
+
registerSchemaTool(server);
|
|
692
|
+
const transport = new StdioServerTransport();
|
|
693
|
+
await server.connect(transport);
|
|
694
|
+
log("server ready (stdio) \u2014 tools: install_panini_connector, verify_panini_connector, generate_shared_secret, get_envelope_schema");
|
|
695
|
+
}
|
|
696
|
+
main().catch((err) => {
|
|
697
|
+
process.stderr.write(`[panini-connector-mcp] fatal: ${err?.stack ?? err}
|
|
698
|
+
`);
|
|
699
|
+
process.exit(1);
|
|
700
|
+
});
|
|
701
|
+
export {
|
|
702
|
+
log
|
|
703
|
+
};
|
|
704
|
+
//# sourceMappingURL=index.js.map
|