@gemmein/mcp 0.2.1 → 0.3.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 +3 -1
- package/dist/index.js +63 -9
- package/dist/relays.js +411 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -24,7 +24,9 @@ Cursor / any MCP client (`mcpServers` config):
|
|
|
24
24
|
|
|
25
25
|
## Tools
|
|
26
26
|
|
|
27
|
-
- **`guide`** —
|
|
27
|
+
- **`guide`** — call first: it opens with the fit assessment (FITS /
|
|
28
|
+
FITS EXCEPT / DOESN'T FIT — the verdict an agent delivers before any
|
|
29
|
+
install), then the full builder's guide (auth flow, the seven collection
|
|
28
30
|
safety rules, record shapes, links, uploads, contention patterns, payments).
|
|
29
31
|
- **`reference`** — the exact SDK API reference: every method, signature,
|
|
30
32
|
return shape, error code.
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
22
22
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
23
23
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
24
24
|
import { gemmein, gemmeinServer } from "@gemmein/sdk";
|
|
25
|
+
import { CLOUD_ONLY_NOTE, explainRelay } from "./relays.js";
|
|
25
26
|
const require = createRequire(import.meta.url);
|
|
26
27
|
function sdkFile(name) {
|
|
27
28
|
try {
|
|
@@ -224,17 +225,20 @@ async function runIntegrationChecks(input) {
|
|
|
224
225
|
const TOOLS = [
|
|
225
226
|
{
|
|
226
227
|
name: "guide",
|
|
227
|
-
description: "
|
|
228
|
+
description: "Call this FIRST — before any install, account, or code — when your human asks to build an app on Gemmein, to move an existing app onto it, or whether their app can use it at all. The guide (llms.txt) opens with the fit assessment: the in-scope map, the out-of-scope list (each item downgrades the verdict; none may be approximated), and the three verdicts you deliver to your human before installing anything — FITS, FITS EXCEPT <named gaps>, or DOESN'T FIT. After the verdict it is the full build contract: auth flow, the seven collection safety rules, record shapes, links/expand, uploads, contention patterns, payments (g.subscriptions.checkout / g.payments.buy), drafts, error philosophy, pricing.",
|
|
229
|
+
annotations: { title: "Guide", readOnlyHint: true },
|
|
228
230
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
229
231
|
},
|
|
230
232
|
{
|
|
231
233
|
name: "reference",
|
|
232
|
-
description: "
|
|
234
|
+
description: "Reach for this while WRITING code against @gemmein/sdk: every method, exact signature, return shape, and the stable error-code table (REFERENCE.md). Use `guide` for how the model works and whether the app fits at all; use `search_docs` when you need one fact from either document.",
|
|
235
|
+
annotations: { title: "Reference", readOnlyHint: true },
|
|
233
236
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
234
237
|
},
|
|
235
238
|
{
|
|
236
239
|
name: "search_docs",
|
|
237
|
-
description: "
|
|
240
|
+
description: "Use when one question comes up mid-build ('keyed create', 'ifVersion', 'addressed', 'expand') and reading a full document would waste context. Searches the guide and the API reference; returns matching passages with 3 lines of context either side, at most 6 match blocks per document. Not the tool for the fit verdict — search finds what the docs say, not what Gemmein refuses to support; call `guide` for that.",
|
|
241
|
+
annotations: { title: "Search docs", readOnlyHint: true },
|
|
238
242
|
inputSchema: {
|
|
239
243
|
type: "object",
|
|
240
244
|
properties: { query: { type: "string", description: "term or phrase to find" } },
|
|
@@ -244,7 +248,8 @@ const TOOLS = [
|
|
|
244
248
|
},
|
|
245
249
|
{
|
|
246
250
|
name: "explain_rule",
|
|
247
|
-
description: "
|
|
251
|
+
description: "Call while DESIGNING a collection — which rule fits this data? — or when a rule refuses something at runtime. One of the seven rules (private, shared, admin_write, public_read, community, addressed, direct) returns its exact access contract, what it is right for, and the mistakes that leak data. Call with no rule for the all-seven cheat-sheet plus the cross-cutting law, including what NO rule supports (team/group/workspace scope, per-user visibility inside a rule) — if the app needs those shapes, that is a fit gap to report to your human, never something to approximate with client-side filtering.",
|
|
252
|
+
annotations: { title: "Explain rule", readOnlyHint: true },
|
|
248
253
|
inputSchema: {
|
|
249
254
|
type: "object",
|
|
250
255
|
properties: {
|
|
@@ -259,7 +264,8 @@ const TOOLS = [
|
|
|
259
264
|
},
|
|
260
265
|
{
|
|
261
266
|
name: "explain_error",
|
|
262
|
-
description: "
|
|
267
|
+
description: "Call the moment a GemmeinError reaches you (err.code: conflict, forbidden, unknown_collection, invalid_shape, html_not_allowed, …): what the code means and the exact next step — including whether the refusal is final (a forbidden repeats on retry; fix the approach, not the request). Parsed from the installed API reference, so codes match the SDK version the app runs. Call with no code to list every stable code.",
|
|
268
|
+
annotations: { title: "Explain error", readOnlyHint: true },
|
|
263
269
|
inputSchema: {
|
|
264
270
|
type: "object",
|
|
265
271
|
properties: { code: { type: "string", description: "the err.code to explain; omit to list all" } },
|
|
@@ -268,7 +274,8 @@ const TOOLS = [
|
|
|
268
274
|
},
|
|
269
275
|
{
|
|
270
276
|
name: "validate_collection_name",
|
|
271
|
-
description: "
|
|
277
|
+
description: "Run at PLANNING time on every collection name you intend to use, before any g.collection(name) call is written. The naming law: lowercase letters, numbers, underscores; starts with a letter; 2-63 characters. A bad name throws from g.collection(name) before any network call — at module load that blanks the whole app with no console error. An invalid name comes back with a suggested fix.",
|
|
278
|
+
annotations: { title: "Validate collection name", readOnlyHint: true },
|
|
272
279
|
inputSchema: {
|
|
273
280
|
type: "object",
|
|
274
281
|
properties: { name: { type: "string" } },
|
|
@@ -278,12 +285,27 @@ const TOOLS = [
|
|
|
278
285
|
},
|
|
279
286
|
{
|
|
280
287
|
name: "reaffirm_template",
|
|
281
|
-
description: "
|
|
288
|
+
description: "Fetch this when you wire up the app's CI, or when you hand the finished app to your human: reaffirm.mjs, the ready-to-edit harness that re-proves the app's boundaries against live Gemmein on every deploy (also shipped inside the @gemmein/sdk package). Copy it next to the app, set the CONFIG block, run it in CI. For a one-off check right now, call check_integration — the same checks with no file to install.",
|
|
289
|
+
annotations: { title: "Reaffirm template", readOnlyHint: true },
|
|
282
290
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
283
291
|
},
|
|
292
|
+
{
|
|
293
|
+
name: "explain_relay",
|
|
294
|
+
description: "Call while WRITING or FIXING gemmein/relays/<name>.json — before `gemmein sync` carries it to the cloud. A relay is one trigger (receiver: a provider's webhook; schedule: a clock; data_change: a record changing) and up to ten actions in Gemmein's own verbs (write_record, grant_access, revoke_access, email_person, call_url) — route, map, authorise, never compute. Pass the definition JSON; the answer is the English sentence the dashboard shows (\"When gocardless-paid receives an event where event_type is confirmed → grant Pro, email the person, call https://…\") or the ONE refusal sentence the cloud would answer, naming the field and the fix. Offline and read-only: nothing is created. Two checks run only in the cloud and are stated in the answer (the API's own hosts; the address's resolved network at call time).",
|
|
295
|
+
annotations: { title: "Explain relay (validate offline)", readOnlyHint: true },
|
|
296
|
+
inputSchema: {
|
|
297
|
+
type: "object",
|
|
298
|
+
properties: {
|
|
299
|
+
definition: { type: "object", description: "the relay definition — the contents of gemmein/relays/<name>.json ({ name, trigger, actions })" },
|
|
300
|
+
},
|
|
301
|
+
required: ["definition"],
|
|
302
|
+
additionalProperties: false,
|
|
303
|
+
},
|
|
304
|
+
},
|
|
284
305
|
{
|
|
285
306
|
name: "check_integration",
|
|
286
|
-
description: "
|
|
307
|
+
description: "Call after wiring the app to Gemmein and before telling your human it is done — and again before go-live. Runs the reaffirm boundary checks live against the caller's own app; returns structured pass/fail (structuredContent: checks, notes, failedCount, passed). Tier A (public pk_ key only): the collection name is valid, anonymous reads and writes of a private collection are refused, an optional public collection reads as its rule intends — safe against any environment, live included. Tier B (add the sk_dev secret key): proves one user cannot read another's private records, using two throwaway test sessions in the DEV environment. sk_live is refused by design — never pass a live secret to any tool; dev and live enforce the same rules, so isolation proven in dev holds in live. The only writes anywhere are Tier B's own probe records in the caller's dev environment, deleted at the end of the check. A failed check means the app's assumptions drifted from its rules — fix before shipping.",
|
|
308
|
+
annotations: { title: "Check integration (live boundary check)", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
287
309
|
inputSchema: {
|
|
288
310
|
type: "object",
|
|
289
311
|
properties: {
|
|
@@ -303,7 +325,24 @@ const TOOLS = [
|
|
|
303
325
|
const server = new Server(
|
|
304
326
|
// The one version, read from package.json — a literal here drifted once
|
|
305
327
|
// (announced 0.1.0 while the package said 0.2.1; review 26 Aug).
|
|
306
|
-
{ name: "gemmein", version: require("../package.json").version }, {
|
|
328
|
+
{ name: "gemmein", version: require("../package.json").version }, {
|
|
329
|
+
capabilities: { tools: {} },
|
|
330
|
+
// Stated ONCE, server-wide, in the initialize result every client
|
|
331
|
+
// hands to its model: what Gemmein is, the read-only law, and the
|
|
332
|
+
// fit protocol (the verdicts live in `guide`).
|
|
333
|
+
instructions: "Gemmein is the go-live system for AI-built web apps: passwordless auth, records " +
|
|
334
|
+
"under seven safety rules, Stripe-run subscriptions and one-off digital products. " +
|
|
335
|
+
"This server is read-only against the platform: no tool here creates, edits, or " +
|
|
336
|
+
"deletes anything on Gemmein. The one write anywhere is check_integration's Tier-B " +
|
|
337
|
+
"probe records, created and then deleted in the caller's own dev environment; " +
|
|
338
|
+
"sk_live keys are refused. Protocol: when the question is whether an app can use " +
|
|
339
|
+
"Gemmein, call guide first — it opens with the fit assessment, and you deliver one " +
|
|
340
|
+
"of three verdicts to your human before installing anything: FITS, FITS EXCEPT " +
|
|
341
|
+
"<named gaps>, or DOESN'T FIT. Then: reference and search_docs while writing code, " +
|
|
342
|
+
"explain_rule while choosing a collection's rule, explain_error when a call is " +
|
|
343
|
+
"refused, explain_relay while writing gemmein/relays/<name>.json, " +
|
|
344
|
+
"check_integration before declaring the app done.",
|
|
345
|
+
});
|
|
307
346
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
308
347
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
309
348
|
const errText = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
@@ -353,6 +392,21 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
|
353
392
|
}
|
|
354
393
|
case "reaffirm_template":
|
|
355
394
|
return text(sdkFile("reaffirm.mjs"));
|
|
395
|
+
case "explain_relay": {
|
|
396
|
+
const definition = args.definition;
|
|
397
|
+
const answer = explainRelay(definition);
|
|
398
|
+
if (!answer.ok) {
|
|
399
|
+
return {
|
|
400
|
+
content: [{ type: "text", text: `REFUSED — ${answer.refusal}\n\nFix that field and call again. ${CLOUD_ONLY_NOTE}` }],
|
|
401
|
+
structuredContent: { ok: false, refusal: answer.refusal },
|
|
402
|
+
isError: true,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
content: [{ type: "text", text: `${answer.sentence}\n\nValid. ${CLOUD_ONLY_NOTE}` }],
|
|
407
|
+
structuredContent: { ok: true, sentence: answer.sentence, definition: answer.definition },
|
|
408
|
+
};
|
|
409
|
+
}
|
|
356
410
|
case "check_integration": {
|
|
357
411
|
const input = args;
|
|
358
412
|
if (typeof input.publicKey !== "string" || !input.publicKey.startsWith("pk_")) {
|
package/dist/relays.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// @gemmein/mcp — `explain_relay`: validate a gemmein/relays/<name>.json
|
|
2
|
+
// definition OFFLINE and answer the English sentence the console shows, or
|
|
3
|
+
// the one refusal sentence the cloud would answer.
|
|
4
|
+
//
|
|
5
|
+
// This is a PORT of the engine's validator (apps/api/src/relays/schema.ts)
|
|
6
|
+
// and its describer, kept in lockstep by test
|
|
7
|
+
// (tests/security/relays/mcpLockstep.test.ts runs both over one fixture
|
|
8
|
+
// set and asserts identical outcomes) — the published server depends only
|
|
9
|
+
// on @gemmein/sdk and cannot import the API. Two cloud-only checks are not
|
|
10
|
+
// reproducible offline and are said in the answer instead: the API's own
|
|
11
|
+
// host list (gemmein.com is refused here; the API host and the file CDN are
|
|
12
|
+
// refused by the cloud) and DNS-time egress rules (private addresses,
|
|
13
|
+
// redirects) that run when the call is made, never at definition time.
|
|
14
|
+
import { isIP } from "node:net";
|
|
15
|
+
export class RelayDefinitionError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.code = "invalid_definition";
|
|
19
|
+
this.name = "RelayDefinitionError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// ── the vocabulary (packages/core/src/relays.ts, mirrored) ──────────
|
|
23
|
+
const RELAY_NAME_RE = /^[a-z][a-z0-9-]{1,62}$/;
|
|
24
|
+
const ACTIONS_PER_RELAY = 10;
|
|
25
|
+
const MAX_RECORD_BYTES = 32 * 1024;
|
|
26
|
+
const SCHEDULE_EVERY = ["15m", "30m", "1h", "6h", "12h", "1d"];
|
|
27
|
+
const HEADER_NAME_RE = /^[a-z0-9-]{1,64}$/i;
|
|
28
|
+
const QUERY_NAME_RE = /^[a-z0-9_-]{1,64}$/i;
|
|
29
|
+
const MAP_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
30
|
+
const PATH_RE = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/;
|
|
31
|
+
const COLLECTION_RE = /^[a-z][a-z0-9_]{1,62}$/;
|
|
32
|
+
const AT_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
33
|
+
const DURATION_RE = /^(\d{1,4})([hdw])$/;
|
|
34
|
+
const MAX_MAP_ENTRIES = 20;
|
|
35
|
+
const MAX_WHEN_ENTRIES = 20;
|
|
36
|
+
const RECORD_KINDS = ["created", "updated", "deleted"];
|
|
37
|
+
const ACTION_TYPES = ["write_record", "grant_access", "revoke_access", "email_person", "call_url"];
|
|
38
|
+
const SCHEMES = ["hmac_sha256_header", "stripe", "svix", "shared_token"];
|
|
39
|
+
const PERSON_ACTIONS = new Set(["grant_access", "revoke_access", "email_person"]);
|
|
40
|
+
const ENTITLEMENT_KEY = /^access:[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
41
|
+
const RESERVED_FIELD_NAMES = new Set([
|
|
42
|
+
"id", "appId", "app_id", "environmentId", "environment_id", "userId", "user_id", "ownerId", "ownerUserId", "owner_user_id",
|
|
43
|
+
"audienceUserId", "audience_user_id", "audienceId", "tenantId", "tenant_id", "role", "isAdmin", "is_admin",
|
|
44
|
+
"createdAt", "created_at", "updatedAt", "updated_at", "deletedAt", "deleted_at", "published",
|
|
45
|
+
]);
|
|
46
|
+
const UNSAFE_KEY_NAMES = new Set([...Object.getOwnPropertyNames(Object.prototype), "prototype"]);
|
|
47
|
+
function isUnsafeName(name) { return UNSAFE_KEY_NAMES.has(name); }
|
|
48
|
+
function findUnsafeKeys(input) { return Object.keys(input).filter((k) => UNSAFE_KEY_NAMES.has(k)); }
|
|
49
|
+
function findReservedFields(input) { return Object.keys(input).filter((k) => RESERVED_FIELD_NAMES.has(k)); }
|
|
50
|
+
function isOwnHost(hostname) {
|
|
51
|
+
const h = hostname.toLowerCase().replace(/\.$/, "");
|
|
52
|
+
return h === "gemmein.com" || h.endsWith(".gemmein.com");
|
|
53
|
+
}
|
|
54
|
+
function refuse(message) { throw new RelayDefinitionError(message); }
|
|
55
|
+
function isPlainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
56
|
+
function requireObject(value, what) {
|
|
57
|
+
if (!isPlainObject(value))
|
|
58
|
+
refuse(`${what} must be a JSON object`);
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
function optionalString(obj, key, what, max = 200) {
|
|
62
|
+
const v = obj[key];
|
|
63
|
+
if (v === undefined || v === null)
|
|
64
|
+
return undefined;
|
|
65
|
+
if (typeof v !== "string")
|
|
66
|
+
refuse(`${what}.${key} must be text`);
|
|
67
|
+
if (v.length > max)
|
|
68
|
+
refuse(`${what}.${key} is too long (max ${max} characters)`);
|
|
69
|
+
return v;
|
|
70
|
+
}
|
|
71
|
+
function requireString(obj, key, what, max = 200) {
|
|
72
|
+
const v = optionalString(obj, key, what, max);
|
|
73
|
+
if (v === undefined || v.trim() === "")
|
|
74
|
+
refuse(`${what}.${key} is required`);
|
|
75
|
+
return v;
|
|
76
|
+
}
|
|
77
|
+
function rejectUnknownKeys(obj, allowed, what) {
|
|
78
|
+
const unknown = Object.keys(obj).filter((k) => !allowed.includes(k));
|
|
79
|
+
if (unknown.length > 0)
|
|
80
|
+
refuse(`${what} has a field the engine does not know: ${unknown.join(", ")} — the fields it knows are ${allowed.join(", ")}`);
|
|
81
|
+
}
|
|
82
|
+
function hasTemplate(value) { return value.includes("{{"); }
|
|
83
|
+
function validateScalarFilter(raw, what) {
|
|
84
|
+
const obj = requireObject(raw, what);
|
|
85
|
+
const keys = Object.keys(obj);
|
|
86
|
+
if (keys.length === 0)
|
|
87
|
+
refuse(`${what} must name at least one field, or be left out`);
|
|
88
|
+
if (keys.length > MAX_WHEN_ENTRIES)
|
|
89
|
+
refuse(`${what} names ${keys.length} fields — at most ${MAX_WHEN_ENTRIES}`);
|
|
90
|
+
const unsafe = findUnsafeKeys(obj);
|
|
91
|
+
if (unsafe.length > 0)
|
|
92
|
+
refuse(`${what} names "${unsafe[0]}", which collides with a JavaScript built-in — pick another field name`);
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const key of keys) {
|
|
95
|
+
const v = obj[key];
|
|
96
|
+
if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") {
|
|
97
|
+
refuse(`${what}.${key} must be a text, number or true/false value — the filter is an exact match per field`);
|
|
98
|
+
}
|
|
99
|
+
out[key] = v;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
function validateVerify(raw) {
|
|
104
|
+
const v = requireObject(raw, "trigger.verify");
|
|
105
|
+
const scheme = requireString(v, "scheme", "trigger.verify", 40);
|
|
106
|
+
if (!SCHEMES.includes(scheme)) {
|
|
107
|
+
refuse(`trigger.verify.scheme "${scheme}" is not one the engine knows — use hmac_sha256_header (a header carrying an HMAC-SHA256 of the body), stripe (t=/v1=), svix (svix-id/svix-timestamp/svix-signature) or shared_token (a token in a header or ?token=)`);
|
|
108
|
+
}
|
|
109
|
+
switch (scheme) {
|
|
110
|
+
case "hmac_sha256_header": {
|
|
111
|
+
rejectUnknownKeys(v, ["scheme", "header", "timestampHeader", "toleranceSeconds", "encoding"], "trigger.verify");
|
|
112
|
+
const header = requireString(v, "header", "trigger.verify", 64);
|
|
113
|
+
if (!HEADER_NAME_RE.test(header))
|
|
114
|
+
refuse(`trigger.verify.header "${header}" is not a header name (letters, digits, hyphens)`);
|
|
115
|
+
const timestampHeader = optionalString(v, "timestampHeader", "trigger.verify", 64);
|
|
116
|
+
if (timestampHeader !== undefined && !HEADER_NAME_RE.test(timestampHeader))
|
|
117
|
+
refuse(`trigger.verify.timestampHeader "${timestampHeader}" is not a header name (letters, digits, hyphens)`);
|
|
118
|
+
let toleranceSeconds;
|
|
119
|
+
if (v.toleranceSeconds !== undefined) {
|
|
120
|
+
if (timestampHeader === undefined)
|
|
121
|
+
refuse("trigger.verify.toleranceSeconds needs trigger.verify.timestampHeader — without a timestamp there is no window to tolerate");
|
|
122
|
+
if (typeof v.toleranceSeconds !== "number" || !Number.isInteger(v.toleranceSeconds) || v.toleranceSeconds < 1 || v.toleranceSeconds > 300) {
|
|
123
|
+
refuse("trigger.verify.toleranceSeconds must be a whole number of seconds between 1 and 300 — five minutes is the most the engine tolerates");
|
|
124
|
+
}
|
|
125
|
+
toleranceSeconds = v.toleranceSeconds;
|
|
126
|
+
}
|
|
127
|
+
const encoding = optionalString(v, "encoding", "trigger.verify", 10);
|
|
128
|
+
if (encoding !== undefined && encoding !== "hex" && encoding !== "base64")
|
|
129
|
+
refuse('trigger.verify.encoding must be "hex" (default) or "base64"');
|
|
130
|
+
return {
|
|
131
|
+
scheme: "hmac_sha256_header",
|
|
132
|
+
header: header.toLowerCase(),
|
|
133
|
+
...(timestampHeader !== undefined ? { timestampHeader: timestampHeader.toLowerCase(), toleranceSeconds: toleranceSeconds ?? 300 } : {}),
|
|
134
|
+
encoding: encoding === "base64" ? "base64" : "hex",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
case "stripe":
|
|
138
|
+
rejectUnknownKeys(v, ["scheme"], "trigger.verify");
|
|
139
|
+
return { scheme: "stripe" };
|
|
140
|
+
case "svix":
|
|
141
|
+
rejectUnknownKeys(v, ["scheme"], "trigger.verify");
|
|
142
|
+
return { scheme: "svix" };
|
|
143
|
+
case "shared_token": {
|
|
144
|
+
rejectUnknownKeys(v, ["scheme", "header", "query"], "trigger.verify");
|
|
145
|
+
const header = optionalString(v, "header", "trigger.verify", 64);
|
|
146
|
+
if (header !== undefined && !HEADER_NAME_RE.test(header))
|
|
147
|
+
refuse(`trigger.verify.header "${header}" is not a header name (letters, digits, hyphens)`);
|
|
148
|
+
const query = optionalString(v, "query", "trigger.verify", 64);
|
|
149
|
+
if (query !== undefined && !QUERY_NAME_RE.test(query))
|
|
150
|
+
refuse(`trigger.verify.query "${query}" is not a query parameter name`);
|
|
151
|
+
return { scheme: "shared_token", header: (header ?? "x-webhook-token").toLowerCase(), query: query ?? "token" };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function validateReceiver(t) {
|
|
156
|
+
rejectUnknownKeys(t, ["kind", "verify", "map", "when"], "trigger");
|
|
157
|
+
if (t.verify === undefined)
|
|
158
|
+
refuse("trigger.verify is required on a receiver — say how the provider signs its calls (hmac_sha256_header, stripe, svix or shared_token)");
|
|
159
|
+
const verify = validateVerify(t.verify);
|
|
160
|
+
let map;
|
|
161
|
+
if (t.map !== undefined) {
|
|
162
|
+
const m = requireObject(t.map, "trigger.map");
|
|
163
|
+
const keys = Object.keys(m);
|
|
164
|
+
if (keys.length > MAX_MAP_ENTRIES)
|
|
165
|
+
refuse(`trigger.map names ${keys.length} fields — at most ${MAX_MAP_ENTRIES}`);
|
|
166
|
+
map = {};
|
|
167
|
+
for (const key of keys) {
|
|
168
|
+
if (isUnsafeName(key) || !MAP_NAME_RE.test(key))
|
|
169
|
+
refuse(`trigger.map field "${key}" must be a simple name (letters, digits, underscores, starting with a letter)`);
|
|
170
|
+
const path = m[key];
|
|
171
|
+
if (typeof path !== "string" || path.length === 0 || path.length > 200 || !PATH_RE.test(path)) {
|
|
172
|
+
refuse(`trigger.map.${key} must be a dotted path into the provider's payload, like events.0.details.customer_email`);
|
|
173
|
+
}
|
|
174
|
+
if (path.split(".").some((seg) => isUnsafeName(seg)))
|
|
175
|
+
refuse(`trigger.map.${key} walks through "${path.split(".").find((seg) => isUnsafeName(seg))}", which is never a field`);
|
|
176
|
+
map[key] = path;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
let when;
|
|
180
|
+
if (t.when !== undefined) {
|
|
181
|
+
when = validateScalarFilter(t.when, "trigger.when");
|
|
182
|
+
for (const key of Object.keys(when)) {
|
|
183
|
+
if (!map || !(key in map))
|
|
184
|
+
refuse(`trigger.when names "${key}", which trigger.map does not define — when filters the MAPPED fields; add "${key}" to the map first`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { kind: "receiver", verify, ...(map ? { map } : {}), ...(when ? { when } : {}) };
|
|
188
|
+
}
|
|
189
|
+
function validateSchedule(t) {
|
|
190
|
+
rejectUnknownKeys(t, ["kind", "every", "at"], "trigger");
|
|
191
|
+
const every = requireString(t, "every", "trigger", 10);
|
|
192
|
+
if (!SCHEDULE_EVERY.includes(every))
|
|
193
|
+
refuse(`trigger.every "${every}" is not a period the engine runs — use one of ${SCHEDULE_EVERY.join(", ")} (nothing under 15 minutes)`);
|
|
194
|
+
const at = optionalString(t, "at", "trigger", 5);
|
|
195
|
+
if (at !== undefined) {
|
|
196
|
+
if (every !== "1d")
|
|
197
|
+
refuse('trigger.at is only for every: "1d" — a shorter period runs on the clock boundary');
|
|
198
|
+
if (!AT_RE.test(at))
|
|
199
|
+
refuse('trigger.at must be "HH:MM" in UTC, like "09:00"');
|
|
200
|
+
}
|
|
201
|
+
return { kind: "schedule", every: every, ...(at !== undefined ? { at } : {}) };
|
|
202
|
+
}
|
|
203
|
+
function validateDataChange(t) {
|
|
204
|
+
rejectUnknownKeys(t, ["kind", "collection", "on", "where"], "trigger");
|
|
205
|
+
const collection = requireString(t, "collection", "trigger", 64);
|
|
206
|
+
if (!COLLECTION_RE.test(collection))
|
|
207
|
+
refuse(`trigger.collection "${collection}" is not a collection name (lowercase letters, digits, underscores)`);
|
|
208
|
+
if (!Array.isArray(t.on) || t.on.length === 0)
|
|
209
|
+
refuse('trigger.on must be a non-empty list from "created", "updated", "deleted"');
|
|
210
|
+
const on = [];
|
|
211
|
+
for (const item of t.on) {
|
|
212
|
+
if (typeof item !== "string" || !RECORD_KINDS.includes(item))
|
|
213
|
+
refuse(`trigger.on contains "${String(item)}" — only created, updated and deleted exist`);
|
|
214
|
+
if (!on.includes(item))
|
|
215
|
+
on.push(item);
|
|
216
|
+
}
|
|
217
|
+
let where;
|
|
218
|
+
if (t.where !== undefined) {
|
|
219
|
+
where = validateScalarFilter(t.where, "trigger.where");
|
|
220
|
+
const reserved = findReservedFields(where);
|
|
221
|
+
if (reserved.length > 0)
|
|
222
|
+
refuse(`trigger.where names "${reserved[0]}", a server-managed field — filter on the record's own data`);
|
|
223
|
+
}
|
|
224
|
+
return { kind: "data_change", collection, on, ...(where ? { where } : {}) };
|
|
225
|
+
}
|
|
226
|
+
function validateTrigger(raw) {
|
|
227
|
+
const t = requireObject(raw, "trigger");
|
|
228
|
+
const kind = requireString(t, "kind", "trigger", 40);
|
|
229
|
+
switch (kind) {
|
|
230
|
+
case "receiver": return validateReceiver(t);
|
|
231
|
+
case "schedule": return validateSchedule(t);
|
|
232
|
+
case "data_change": return validateDataChange(t);
|
|
233
|
+
default:
|
|
234
|
+
return refuse(`trigger.kind "${kind}" is not a trigger the engine has — use receiver (a provider's webhook), schedule (a clock) or data_change (a record changing)`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function validateEntitlementInput(value, what) {
|
|
238
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
239
|
+
refuse(`${what}.entitlement is required — a plan or product name, or an access:<name> key`);
|
|
240
|
+
if (hasTemplate(value))
|
|
241
|
+
refuse(`${what}.entitlement cannot carry a template — a grant must name what it grants`);
|
|
242
|
+
if (value.length > 100)
|
|
243
|
+
refuse(`${what}.entitlement is too long (max 100 characters)`);
|
|
244
|
+
if (/^access:/i.test(value)) {
|
|
245
|
+
if (!ENTITLEMENT_KEY.test(value.toLowerCase())) {
|
|
246
|
+
refuse(`${what}.entitlement: requires must look like access:<name> — lowercase letters, numbers, dot, dash or underscore, up to 64 characters. access is the only kind that exists: credits, quotas and seats aren't supported yet`);
|
|
247
|
+
}
|
|
248
|
+
const slug = value.slice(value.indexOf(":") + 1);
|
|
249
|
+
if (isUnsafeName(slug))
|
|
250
|
+
refuse(`${what}.entitlement "${value}" collides with a JavaScript built-in — name the plan something a person would say`);
|
|
251
|
+
}
|
|
252
|
+
return value.trim();
|
|
253
|
+
}
|
|
254
|
+
function validateAction(raw, index, triggerKind) {
|
|
255
|
+
const what = `actions[${index}]`;
|
|
256
|
+
const a = requireObject(raw, what);
|
|
257
|
+
const type = requireString(a, "type", what, 40);
|
|
258
|
+
if (!ACTION_TYPES.includes(type)) {
|
|
259
|
+
refuse(`${what}.type "${type}" is not an action the engine has — the verbs are ${ACTION_TYPES.join(", ")}; anything else is compute, and compute runs on your own server behind call_url`);
|
|
260
|
+
}
|
|
261
|
+
if (triggerKind === "schedule" && PERSON_ACTIONS.has(type)) {
|
|
262
|
+
refuse(`${what}: a schedule has no person, so ${type} has no one to act on — trigger it from a receiver whose map names person_email, or from a data_change on a collection whose records have an owner`);
|
|
263
|
+
}
|
|
264
|
+
switch (type) {
|
|
265
|
+
case "write_record": {
|
|
266
|
+
rejectUnknownKeys(a, ["type", "collection", "data", "to"], what);
|
|
267
|
+
const collection = requireString(a, "collection", what, 64);
|
|
268
|
+
if (!COLLECTION_RE.test(collection))
|
|
269
|
+
refuse(`${what}.collection "${collection}" is not a collection name (lowercase letters, digits, underscores)`);
|
|
270
|
+
const data = requireObject(a.data, `${what}.data`);
|
|
271
|
+
if (Object.keys(data).length === 0)
|
|
272
|
+
refuse(`${what}.data must carry at least one field`);
|
|
273
|
+
const reserved = findReservedFields(data);
|
|
274
|
+
if (reserved.length > 0)
|
|
275
|
+
refuse(`${what}.data names "${reserved[0]}", a server-managed field — the engine writes it`);
|
|
276
|
+
const unsafe = findUnsafeKeys(data);
|
|
277
|
+
if (unsafe.length > 0)
|
|
278
|
+
refuse(`${what}.data names "${unsafe[0]}", which collides with a JavaScript built-in`);
|
|
279
|
+
if (Buffer.byteLength(JSON.stringify(data), "utf8") > MAX_RECORD_BYTES)
|
|
280
|
+
refuse(`${what}.data: Storage record payload exceeds ${MAX_RECORD_BYTES} bytes`);
|
|
281
|
+
const to = optionalString(a, "to", what, 10);
|
|
282
|
+
if (to !== undefined && to !== "person")
|
|
283
|
+
refuse(`${what}.to can only be "person" (the record is addressed to the event's person) — leave it out for an app-owned record`);
|
|
284
|
+
if (to === "person" && triggerKind === "schedule") {
|
|
285
|
+
refuse(`${what}: a schedule has no person, so to: "person" has no one to address — leave it out, or trigger from a receiver or a data_change`);
|
|
286
|
+
}
|
|
287
|
+
return { type: "write_record", collection, data: structuredClone(data), ...(to === "person" ? { to: "person" } : {}) };
|
|
288
|
+
}
|
|
289
|
+
case "grant_access": {
|
|
290
|
+
rejectUnknownKeys(a, ["type", "entitlement", "expiresAt", "reason"], what);
|
|
291
|
+
const entitlement = validateEntitlementInput(a.entitlement, what);
|
|
292
|
+
const expiresAt = optionalString(a, "expiresAt", what, 40);
|
|
293
|
+
if (expiresAt !== undefined && !DURATION_RE.test(expiresAt) && Number.isNaN(Date.parse(expiresAt))) {
|
|
294
|
+
refuse(`${what}.expiresAt must be a duration from the moment of the grant ("30d", "12h", "2w") or an ISO date`);
|
|
295
|
+
}
|
|
296
|
+
const reason = optionalString(a, "reason", what, 200);
|
|
297
|
+
return { type: "grant_access", entitlement, ...(expiresAt !== undefined ? { expiresAt } : {}), ...(reason !== undefined ? { reason } : {}) };
|
|
298
|
+
}
|
|
299
|
+
case "revoke_access": {
|
|
300
|
+
rejectUnknownKeys(a, ["type", "entitlement"], what);
|
|
301
|
+
return { type: "revoke_access", entitlement: validateEntitlementInput(a.entitlement, what) };
|
|
302
|
+
}
|
|
303
|
+
case "email_person": {
|
|
304
|
+
rejectUnknownKeys(a, ["type", "subject", "text", "kind"], what);
|
|
305
|
+
const subject = requireString(a, "subject", what, 300);
|
|
306
|
+
const text = requireString(a, "text", what, 10000);
|
|
307
|
+
const kind = optionalString(a, "kind", what, 10);
|
|
308
|
+
if (kind !== undefined && kind !== "event" && kind !== "account")
|
|
309
|
+
refuse(`${what}.kind must be "event" (capped per person) or "account" (account activity — sign-in, access, billing trouble)`);
|
|
310
|
+
return { type: "email_person", subject: subject.replace(/[\r\n]+/g, " ").trim(), text, ...(kind !== undefined ? { kind: kind } : {}) };
|
|
311
|
+
}
|
|
312
|
+
case "call_url": {
|
|
313
|
+
rejectUnknownKeys(a, ["type", "url"], what);
|
|
314
|
+
const url = requireString(a, "url", what, 2048);
|
|
315
|
+
if (hasTemplate(url))
|
|
316
|
+
refuse(`${what}.url cannot carry a template — the address is checked before every call, and a substituted address would be a different one`);
|
|
317
|
+
let parsed;
|
|
318
|
+
try {
|
|
319
|
+
parsed = new URL(url);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
return refuse(`${what}.url must be an absolute https:// URL`);
|
|
323
|
+
}
|
|
324
|
+
const host = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
325
|
+
if (parsed.protocol !== "https:")
|
|
326
|
+
refuse(`${what}.url must be https:// — http:// and every other scheme are refused`);
|
|
327
|
+
if (isIP(host) !== 0)
|
|
328
|
+
refuse(`${what}.url must name a host, not an IP address`);
|
|
329
|
+
if (isOwnHost(host))
|
|
330
|
+
refuse(`${what}.url must not point at gemmein.com, a gemmein.com subdomain, the API's own host or the file CDN`);
|
|
331
|
+
if (parsed.username || parsed.password)
|
|
332
|
+
refuse(`${what}.url must not carry credentials — put a secret in your function, not in the address`);
|
|
333
|
+
return { type: "call_url", url };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** The cloud's rules (localMode off: https only). Throws
|
|
338
|
+
* RelayDefinitionError with the one sentence. */
|
|
339
|
+
export function validateRelayDefinition(input) {
|
|
340
|
+
const def = requireObject(input, "the relay");
|
|
341
|
+
rejectUnknownKeys(def, ["name", "trigger", "actions"], "the relay");
|
|
342
|
+
const name = requireString(def, "name", "the relay", 64);
|
|
343
|
+
if (!RELAY_NAME_RE.test(name))
|
|
344
|
+
refuse(`name "${name}" must be 2–63 lowercase letters, digits and hyphens, starting with a letter — it becomes the receiver URL and the file name`);
|
|
345
|
+
const trigger = validateTrigger(def.trigger);
|
|
346
|
+
if (!Array.isArray(def.actions) || def.actions.length === 0)
|
|
347
|
+
refuse("actions must be a non-empty list — a relay that does nothing is not one");
|
|
348
|
+
if (def.actions.length > ACTIONS_PER_RELAY)
|
|
349
|
+
refuse(`actions lists ${def.actions.length} — at most ${ACTIONS_PER_RELAY} per relay; split the rest into a second relay`);
|
|
350
|
+
const actions = def.actions.map((raw, i) => validateAction(raw, i, trigger.kind));
|
|
351
|
+
return { name, trigger, actions };
|
|
352
|
+
}
|
|
353
|
+
// ── the English sentence (schema.ts describeRelay, mirrored) ────────
|
|
354
|
+
const EVERY_WORDS = {
|
|
355
|
+
"15m": "Every 15 minutes",
|
|
356
|
+
"30m": "Every 30 minutes",
|
|
357
|
+
"1h": "Every hour",
|
|
358
|
+
"6h": "Every 6 hours",
|
|
359
|
+
"12h": "Every 12 hours",
|
|
360
|
+
"1d": "Every day",
|
|
361
|
+
};
|
|
362
|
+
function filterWords(filter) {
|
|
363
|
+
if (!filter)
|
|
364
|
+
return "";
|
|
365
|
+
return " where " + Object.entries(filter).map(([k, v]) => `${k} is ${typeof v === "string" ? v : String(v)}`).join(" and ");
|
|
366
|
+
}
|
|
367
|
+
function listWords(items) {
|
|
368
|
+
if (items.length <= 1)
|
|
369
|
+
return items[0] ?? "";
|
|
370
|
+
return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
|
|
371
|
+
}
|
|
372
|
+
function entitlementWords(value) {
|
|
373
|
+
return value.startsWith("access:") ? value.slice("access:".length) : value;
|
|
374
|
+
}
|
|
375
|
+
export function describeRelay(def) {
|
|
376
|
+
const t = def.trigger;
|
|
377
|
+
let when;
|
|
378
|
+
if (t.kind === "receiver")
|
|
379
|
+
when = `When ${def.name} receives an event${filterWords(t.when)}`;
|
|
380
|
+
else if (t.kind === "schedule")
|
|
381
|
+
when = t.at ? `${EVERY_WORDS[t.every]} at ${t.at} UTC` : EVERY_WORDS[t.every];
|
|
382
|
+
else
|
|
383
|
+
when = `When a ${t.collection} record is ${listWords(t.on)}${filterWords(t.where)}`;
|
|
384
|
+
const actions = def.actions.map((a) => {
|
|
385
|
+
switch (a.type) {
|
|
386
|
+
case "write_record": return `write a ${a.collection} record${a.to === "person" ? " for the person" : ""}`;
|
|
387
|
+
case "grant_access": return `grant ${entitlementWords(a.entitlement)}`;
|
|
388
|
+
case "revoke_access": return `revoke ${entitlementWords(a.entitlement)}`;
|
|
389
|
+
case "email_person": return "email the person";
|
|
390
|
+
case "call_url": return `call ${a.url}`;
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
return `${when} → ${actions.join(", ")}`;
|
|
394
|
+
}
|
|
395
|
+
/** The tool's answer: the sentence, or the refusal, plus what only the
|
|
396
|
+
* cloud can check. Never throws on a bad definition. */
|
|
397
|
+
export function explainRelay(input) {
|
|
398
|
+
try {
|
|
399
|
+
const definition = validateRelayDefinition(input);
|
|
400
|
+
return { ok: true, sentence: describeRelay(definition), definition };
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
if (error instanceof RelayDefinitionError)
|
|
404
|
+
return { ok: false, refusal: error.message };
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
export const CLOUD_ONLY_NOTE = "Checked offline with the cloud's definition rules. Two things are checked only when it runs there: " +
|
|
409
|
+
"call_url must not point at the API's own host or the file CDN (gemmein.com is refused here), and at call time the address " +
|
|
410
|
+
"must resolve to a public host (loopback, private and link-local ranges are refused; redirects are not followed). " +
|
|
411
|
+
"Locally, `gemmein dev` also allows call_url to http://localhost — the cloud never does.";
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemmein/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"mcpName": "com.gemmein/mcp",
|
|
4
5
|
"description": "Gemmein MCP server \u2014 gives coding agents the Gemmein guide, API reference, rule/error explainers, and a live integration check (reaffirm) as tools. Read-only: it never creates, edits, or deletes anything.",
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"type": "module",
|
|
@@ -11,7 +12,7 @@
|
|
|
11
12
|
"dist"
|
|
12
13
|
],
|
|
13
14
|
"engines": {
|
|
14
|
-
"node": ">=
|
|
15
|
+
"node": ">=20"
|
|
15
16
|
},
|
|
16
17
|
"keywords": [
|
|
17
18
|
"mcp",
|
|
@@ -33,7 +34,7 @@
|
|
|
33
34
|
"prepublishOnly": "npm run build"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
36
|
-
"@gemmein/sdk": "^0.
|
|
37
|
+
"@gemmein/sdk": "^0.6.0",
|
|
37
38
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
38
39
|
},
|
|
39
40
|
"author": "Gemmein Limited",
|