@gemmein/mcp 0.2.2 → 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/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 {
@@ -288,6 +289,19 @@ const TOOLS = [
288
289
  annotations: { title: "Reaffirm template", readOnlyHint: true },
289
290
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
290
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
+ },
291
305
  {
292
306
  name: "check_integration",
293
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.",
@@ -326,7 +340,8 @@ const server = new Server(
326
340
  "of three verdicts to your human before installing anything: FITS, FITS EXCEPT " +
327
341
  "<named gaps>, or DOESN'T FIT. Then: reference and search_docs while writing code, " +
328
342
  "explain_rule while choosing a collection's rule, explain_error when a call is " +
329
- "refused, check_integration before declaring the app done.",
343
+ "refused, explain_relay while writing gemmein/relays/<name>.json, " +
344
+ "check_integration before declaring the app done.",
330
345
  });
331
346
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
332
347
  const text = (t) => ({ content: [{ type: "text", text: t }] });
@@ -377,6 +392,21 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
377
392
  }
378
393
  case "reaffirm_template":
379
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
+ }
380
410
  case "check_integration": {
381
411
  const input = args;
382
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,8 +1,8 @@
1
1
  {
2
2
  "name": "@gemmein/mcp",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "mcpName": "com.gemmein/mcp",
5
- "description": "Gemmein MCP server 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
+ "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.",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "dist"
13
13
  ],
14
14
  "engines": {
15
- "node": ">=18"
15
+ "node": ">=20"
16
16
  },
17
17
  "keywords": [
18
18
  "mcp",
@@ -34,7 +34,7 @@
34
34
  "prepublishOnly": "npm run build"
35
35
  },
36
36
  "dependencies": {
37
- "@gemmein/sdk": "^0.4.4",
37
+ "@gemmein/sdk": "^0.6.0",
38
38
  "@modelcontextprotocol/sdk": "^1.29.0"
39
39
  },
40
40
  "author": "Gemmein Limited",