@cookwala/mcp 0.1.0 → 0.2.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/package.json +1 -1
- package/server.json +2 -2
- package/src/core/certify.js +97 -0
- package/src/core/dryrun.js +9 -5
- package/src/core/envelope.js +7 -2
- package/src/core/index.js +1 -0
- package/src/core/mandate.js +6 -1
- package/src/core/vocab.js +19 -0
- package/src/server.js +39 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cookwala/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"mcpName": "ai.cookwala/cookwala",
|
|
5
5
|
"description": "Cookwala MCP server: search and read recipes, dry-run them against a device, check safe bands, mandates and humanitarian SMS. Read-only; reads the static catalog on cookwala.ai; never starts cooking.",
|
|
6
6
|
"license": "Apache-2.0",
|
package/server.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "ai.cookwala/cookwala",
|
|
4
4
|
"title": "Cookwala",
|
|
5
5
|
"description": "Search Cookwala recipes, dry-run them on a device, check safe bands, mandates and SMS. Read-only.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.2.0",
|
|
7
7
|
"websiteUrl": "https://cookwala.ai/mcp/",
|
|
8
8
|
"repository": {
|
|
9
9
|
"url": "https://github.com/amado2k5/cookwala",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
17
17
|
"identifier": "@cookwala/mcp",
|
|
18
|
-
"version": "0.
|
|
18
|
+
"version": "0.2.0",
|
|
19
19
|
"runtimeHint": "npx",
|
|
20
20
|
"transport": {
|
|
21
21
|
"type": "stdio"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Certifications (RFC-0010): verify one document and work out which ones currently hold.
|
|
2
|
+
// Port of verify_certification / current_certifications in tools/cookwala_ref.py; the reasons are the same strings.
|
|
3
|
+
// Pure: Web Crypto Ed25519 only, so it runs in Node 20+ and in browsers that support Ed25519.
|
|
4
|
+
import { canonical, docHash } from './jcs.js';
|
|
5
|
+
import { parseTime } from './envelope.js';
|
|
6
|
+
|
|
7
|
+
const unb64u = (s) => {
|
|
8
|
+
const b = atob(s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4));
|
|
9
|
+
return Uint8Array.from(b, (c) => c.charCodeAt(0));
|
|
10
|
+
};
|
|
11
|
+
const time = (s) => { const t = parseTime(s); if (!Number.isFinite(t)) throw new RangeError('invalid_timestamp'); return t; };
|
|
12
|
+
|
|
13
|
+
/** RFC-0012 signing input: canonical JSON of the signing header. */
|
|
14
|
+
export function signingInput(h, kid, alg, signedAt, kind) {
|
|
15
|
+
const hdr = { alg, hash: h, kid, signedAt };
|
|
16
|
+
if (kind) hdr.kind = kind;
|
|
17
|
+
return new TextEncoder().encode(canonical(hdr));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function keyFor(sig, keys) {
|
|
21
|
+
if (!sig) return [null, 'unsigned'];
|
|
22
|
+
if (!sig.signedAt) return [null, 'unsigned_time'];
|
|
23
|
+
const rec = (keys || []).find((k) => k.kid === sig.kid);
|
|
24
|
+
if (!rec) return [null, 'unknown_key'];
|
|
25
|
+
if (rec.alg !== sig.alg) return [null, 'alg_mismatch'];
|
|
26
|
+
if (sig.alg !== 'EdDSA') return [null, 'alg_not_supported_by_reference'];
|
|
27
|
+
let when;
|
|
28
|
+
try { when = time(sig.signedAt); } catch { return [null, 'invalid_timestamp']; }
|
|
29
|
+
try {
|
|
30
|
+
if (when < time(rec.validFrom) || (rec.validTo && when > time(rec.validTo))) return [null, 'key_not_valid_at_signing_time'];
|
|
31
|
+
if (rec.revokedAt && when >= time(rec.revokedAt)) return [null, 'key_revoked'];
|
|
32
|
+
} catch { return [null, 'invalid_timestamp']; }
|
|
33
|
+
return [rec, 'ok'];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Verify doc.signature over its hash. Resolves to [ok, reason]. */
|
|
37
|
+
export async function verifyDocument(doc, keys) {
|
|
38
|
+
const sig = doc && doc.signature;
|
|
39
|
+
const [rec, why] = keyFor(sig, keys);
|
|
40
|
+
if (!rec) return [false, why];
|
|
41
|
+
try {
|
|
42
|
+
const key = await globalThis.crypto.subtle.importKey('raw', unb64u(rec.publicKey), { name: 'Ed25519' }, false, ['verify']);
|
|
43
|
+
const ok = await globalThis.crypto.subtle.verify({ name: 'Ed25519' }, key, unb64u(sig.sig), signingInput(await docHash(doc), sig.kid, sig.alg, sig.signedAt, doc.kind));
|
|
44
|
+
return ok ? [true, 'ok'] : [false, 'bad_signature'];
|
|
45
|
+
} catch { return [false, 'bad_signature']; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One Certification: signature, subject hash, status, validity window. Resolves to [ok, reason]. */
|
|
49
|
+
export async function verifyCertification(cert, keys, now = null, subjectHash = null) {
|
|
50
|
+
if (!cert || cert.kind !== 'Certification') return [false, 'not_a_certification'];
|
|
51
|
+
const [ok, why] = await verifyDocument(cert, keys);
|
|
52
|
+
if (!ok) return [false, why];
|
|
53
|
+
if (subjectHash && (cert.subject || {}).hash !== subjectHash) return [false, 'subject_mismatch'];
|
|
54
|
+
try {
|
|
55
|
+
const t = now ? time(now) : Date.now();
|
|
56
|
+
if (cert.status === 'revoked' && (!cert.revokedAt || t >= time(cert.revokedAt))) return [false, 'revoked'];
|
|
57
|
+
if (cert.status === 'suspended') return [false, 'suspended'];
|
|
58
|
+
if (cert.validFrom && t < time(cert.validFrom)) return [false, 'not_yet_valid'];
|
|
59
|
+
if (cert.validUntil && t > time(cert.validUntil)) return [false, 'expired'];
|
|
60
|
+
} catch { return [false, 'invalid_timestamp']; }
|
|
61
|
+
return [true, 'ok'];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Which certifications hold now: newest verifying document per (authority, scheme); older ones are superseded. */
|
|
65
|
+
export async function currentCertifications(certs, keys, now = null, subjectHash = null) {
|
|
66
|
+
const verdicts = new Map();
|
|
67
|
+
for (const c of certs) verdicts.set(c.id, await verifyCertification(c, keys, now, subjectHash));
|
|
68
|
+
const groups = new Map();
|
|
69
|
+
for (const c of certs) {
|
|
70
|
+
if (!verdicts.get(c.id)[0]) continue;
|
|
71
|
+
const k = JSON.stringify([(c.authority || {}).id ?? null, c.scheme ?? null]);
|
|
72
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
73
|
+
groups.get(k).push(c);
|
|
74
|
+
}
|
|
75
|
+
const rejected = {};
|
|
76
|
+
for (const [id, [ok, why]] of verdicts) if (!ok) rejected[id] = why;
|
|
77
|
+
const current = [];
|
|
78
|
+
for (const members of groups.values()) {
|
|
79
|
+
members.sort((a, b) => ((a.issuedAt || '') < (b.issuedAt || '') ? -1 : (a.issuedAt || '') > (b.issuedAt || '') ? 1 : 0));
|
|
80
|
+
current.push(members[members.length - 1].id);
|
|
81
|
+
for (const older of members.slice(0, -1)) rejected[older.id] = 'superseded';
|
|
82
|
+
}
|
|
83
|
+
return { current: current.sort(), rejected };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** currentCertifications for a mixed list: one call per subject hash, so one subject never supersedes another. */
|
|
87
|
+
export async function currentBySubject(certs, keys, now = null) {
|
|
88
|
+
const bySubject = new Map();
|
|
89
|
+
for (const c of certs) { const h = (c.subject || {}).hash || ''; if (!bySubject.has(h)) bySubject.set(h, []); bySubject.get(h).push(c); }
|
|
90
|
+
const out = { current: [], rejected: {} };
|
|
91
|
+
for (const [h, group] of bySubject) {
|
|
92
|
+
const r = await currentCertifications(group, keys, now, h || null);
|
|
93
|
+
out.current.push(...r.current); Object.assign(out.rejected, r.rejected);
|
|
94
|
+
}
|
|
95
|
+
out.current.sort();
|
|
96
|
+
return out;
|
|
97
|
+
}
|
package/src/core/dryrun.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// Core 0.2 dry run: can this device cook this recipe? Nothing is executed.
|
|
2
2
|
// Port of tools/cookwala_ref.py dry_run; same result shape (state, refusal, plan).
|
|
3
|
-
import { checkNodeParams, ladderChoice, trustedSensors } from './envelope.js';
|
|
3
|
+
import { checkNodeParams, ladderChoice, trustedSensors, opsIndex } from './envelope.js';
|
|
4
|
+
import { normalizeVocab } from './vocab.js';
|
|
4
5
|
|
|
5
6
|
export function dryRun(vocab, recipe, capabilities, { humanPresent = false, allowModel = true, limits = null, now = null } = {}) {
|
|
7
|
+
// Transform vocab to expected format (handles both raw {entries: [...]} and pre-transformed formats)
|
|
8
|
+
const vocabNorm = normalizeVocab(vocab);
|
|
9
|
+
const vocabWithIndex = vocabNorm.ops ? vocabNorm : opsIndex(vocab);
|
|
6
10
|
const caps = capabilities.capabilities || {};
|
|
7
|
-
const ops = new Set((caps.ops || []).map((o) => o.op).filter((id) => ((
|
|
11
|
+
const ops = new Set((caps.ops || []).map((o) => o.op).filter((id) => ((vocabWithIndex.ops[id] || {}).executable) !== false));
|
|
8
12
|
const sensors = trustedSensors(capabilities, now);
|
|
9
13
|
const plan = [];
|
|
10
14
|
for (const node of ((recipe.process || {}).nodes) || []) {
|
|
@@ -14,14 +18,14 @@ export function dryRun(vocab, recipe, capabilities, { humanPresent = false, allo
|
|
|
14
18
|
if (humanPresent && (allowed.includes('human') || allowed.includes('any'))) { plan.push({ node: node.id, op, by: 'human', verifiedBy: 'human' }); continue; }
|
|
15
19
|
return { state: 'refused', refusal: { reason: 'missing_capability', node: node.id, detail: `device cannot perform ${op} and no person is present to do it` }, plan };
|
|
16
20
|
}
|
|
17
|
-
const bad = checkNodeParams(
|
|
21
|
+
const bad = checkNodeParams(vocabWithIndex, op, node, limits);
|
|
18
22
|
if (bad) return { state: 'refused', refusal: { reason: bad[0], node: node.id, detail: bad[1] }, plan };
|
|
19
|
-
const env = (
|
|
23
|
+
const env = (vocabWithIndex.ops[op] || {}).envelope || {};
|
|
20
24
|
const hasEnv = Object.keys(env).length > 0;
|
|
21
25
|
if (hasEnv && env.unattended === false && !humanPresent) {
|
|
22
26
|
return { state: 'refused', refusal: { reason: 'needs_human_present', node: node.id, detail: `${op} may not run unattended` }, plan };
|
|
23
27
|
}
|
|
24
|
-
const rung = hasEnv ? ladderChoice(
|
|
28
|
+
const rung = hasEnv ? ladderChoice(vocabWithIndex, op, sensors, allowModel, humanPresent) : 'time';
|
|
25
29
|
if (rung === null) return { state: 'refused', refusal: { reason: 'missing_sensor_no_fallback', node: node.id, detail: `no way to verify ${op} on this device` }, plan };
|
|
26
30
|
plan.push({ node: node.id, op, by: 'device', verifiedBy: /^(cw\.|x-)/.test(rung) ? 'sensor' : rung, rung });
|
|
27
31
|
}
|
package/src/core/envelope.js
CHANGED
|
@@ -39,13 +39,18 @@ export function checkEnvelope(vocab, opId, readings, target = null, altitudeM =
|
|
|
39
39
|
return { envelopeOk: envOk, targetOk: tgtOk, reason };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// RFC 3339 date-time with an offset, the same rule as the Python reference (BACKLOG P-12). NaN otherwise.
|
|
43
|
+
const RFC3339 = /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
44
|
+
export const parseTime = (s) => (typeof s === 'string' && RFC3339.test(s) ? Date.parse(s.toUpperCase().replace(' ', 'T')) : NaN);
|
|
45
|
+
|
|
42
46
|
export function trustedSensors(capabilities, now) {
|
|
43
|
-
const t = now ?
|
|
47
|
+
const t = now ? parseTime(now) : Date.now();
|
|
48
|
+
if (!Number.isFinite(t)) throw new RangeError('invalid_timestamp');
|
|
44
49
|
const out = new Set();
|
|
45
50
|
for (const s of ((capabilities.capabilities || {}).sensors) || []) {
|
|
46
51
|
if ((s.state || 'ok') !== 'ok') continue;
|
|
47
52
|
const vu = s.calibration && s.calibration.validUntil;
|
|
48
|
-
if (vu
|
|
53
|
+
if (vu) { const u = parseTime(vu); if (!Number.isFinite(u) || t > u) continue; } // unreadable calibration date: not trusted
|
|
49
54
|
out.add(s.sensor);
|
|
50
55
|
(s.visionCues || []).forEach((c) => out.add(c));
|
|
51
56
|
}
|
package/src/core/index.js
CHANGED
package/src/core/mandate.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { parseTime } from './envelope.js';
|
|
1
2
|
// Is an action inside an AgentMandate? Port of check_mandate in sdk/mcp/cookwala_mcp.py.
|
|
2
3
|
export function checkMandate(m, action, amount, provider, now) {
|
|
3
4
|
const reasons = [];
|
|
4
5
|
if (action === 'irreversible' || action === 'safety_override') return { allowed: false, needsConfirmation: true, reasons: ['always_confirm'] };
|
|
5
6
|
if (!(m.scopes || []).includes(action)) reasons.push('scope_missing');
|
|
6
|
-
if (now && m.expires
|
|
7
|
+
if (now && m.expires) {
|
|
8
|
+
const t = parseTime(now), e = parseTime(m.expires);
|
|
9
|
+
if (!Number.isFinite(t) || !Number.isFinite(e)) reasons.push('invalid_timestamp'); // an unreadable expiry never allows anything
|
|
10
|
+
else if (t > e) reasons.push('expired');
|
|
11
|
+
}
|
|
7
12
|
if (provider && (m.allowedProviders || []).length && !m.allowedProviders.includes(provider)) reasons.push('provider_not_allowed');
|
|
8
13
|
if (amount !== undefined && amount !== null) {
|
|
9
14
|
const amt = Number(amount);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Vocab loading and transformation — mirrors Python's _vocab() function
|
|
2
|
+
// Transforms {entries: [{id: '...', ...}, ...]} → {id: {...}, ...}
|
|
3
|
+
|
|
4
|
+
export function normalizeVocab(vocab) {
|
|
5
|
+
// If already normalized (direct id→entry map), return as-is
|
|
6
|
+
if (vocab.entries === undefined && !Array.isArray(vocab)) {
|
|
7
|
+
return vocab;
|
|
8
|
+
}
|
|
9
|
+
// If in {entries: [...]} format, transform to {id: entry}
|
|
10
|
+
if (Array.isArray(vocab.entries)) {
|
|
11
|
+
const result = {};
|
|
12
|
+
for (const entry of vocab.entries) {
|
|
13
|
+
result[entry.id] = entry;
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
// Already in correct format (though missing entries)
|
|
18
|
+
return vocab;
|
|
19
|
+
}
|
package/src/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Catalog, CatalogError } from './catalog.js';
|
|
|
7
7
|
import {
|
|
8
8
|
searchRecipes, explainStep, listOperations, checkEnvelope, dryRun, checkMandate, parseSms, docHash,
|
|
9
9
|
recipeView, LEVEL_NOTE, shapeFifi, searchFifiMap, collectionOf,
|
|
10
|
+
verifyCertification, currentCertifications, currentBySubject,
|
|
10
11
|
} from './core/index.js';
|
|
11
12
|
|
|
12
13
|
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
@@ -30,10 +31,22 @@ const wrap = (fn) => async (args) => {
|
|
|
30
31
|
try { return await fn(args); }
|
|
31
32
|
catch (e) {
|
|
32
33
|
if (e instanceof CatalogError) return fail(e.code, e.detail, { path: e.path });
|
|
34
|
+
if (e instanceof RangeError && e.message === 'invalid_timestamp') return fail('invalid_timestamp', 'times must be RFC 3339 date-times with an offset, for example 2026-10-07T12:00:00Z');
|
|
33
35
|
return fail('internal_error', String(e && e.message || e));
|
|
34
36
|
}
|
|
35
37
|
};
|
|
36
38
|
|
|
39
|
+
const CERT_KEYS = {
|
|
40
|
+
keys: z.array(z.record(z.any())).optional().describe('KeyRecords of the authorities you trust'),
|
|
41
|
+
keys_path: z.string().regex(/^\/v1\/[A-Za-z0-9._\/-]+\.json$/).optional().describe('A KeyRecord list published on the catalog, for example /v1/conformance/keys/certification-test-keys.json'),
|
|
42
|
+
};
|
|
43
|
+
const certKeys = async (cat, a) => {
|
|
44
|
+
if (a.keys) return a.keys;
|
|
45
|
+
if (!a.keys_path || a.keys_path.includes('..')) return null;
|
|
46
|
+
const k = await cat.json(a.keys_path);
|
|
47
|
+
return Array.isArray(k) ? k : (k && k.keys) || [];
|
|
48
|
+
};
|
|
49
|
+
|
|
37
50
|
const NOTE_DRY = 'Dry run only. Starting an execution needs a hub, a mandate with start_cooking, and the device enforces its own safety limits.';
|
|
38
51
|
|
|
39
52
|
/** The tool table: the contract documented at https://cookwala.ai/mcp/. Exported for tests and docs. */
|
|
@@ -112,6 +125,32 @@ export function buildTools(cat) {
|
|
|
112
125
|
description: 'Recompute the document hash of a recipe object and compare it with the hash it declares. Executors refuse a mismatch.',
|
|
113
126
|
shape: { recipe: z.record(z.any()) },
|
|
114
127
|
run: async (a) => { const hash = await docHash(a.recipe); return ok({ hash, declaredHash: a.recipe.hash, matches: a.recipe.hash === hash, level: a.recipe.verification && a.recipe.verification.level }); } },
|
|
128
|
+
{ name: 'verify_certification', title: 'Verify a certification', annotations: NET,
|
|
129
|
+
description: 'Verify a signed Certification (halal, kosher, vegetarian, ...; RFC-0010): the authority signature against the KeyRecords you trust, the subject hash, status and validity window. Give certification_id (from the catalog) or a certification object, and keys or keys_path. There is no default trust list: keys_path /v1/conformance/keys/certification-test-keys.json holds only the public test keys of the fictional example authorities.',
|
|
130
|
+
shape: { certification_id: z.string().optional(), certification: z.record(z.any()).optional(), ...CERT_KEYS,
|
|
131
|
+
subject_hash: z.string().optional().describe('Hash of the recipe revision you hold; omit to skip the subject check'), recipe_id: z.string().optional().describe('Alternative to subject_hash: use this catalog recipe\'s hash'), now: z.string().optional() },
|
|
132
|
+
run: async (a) => {
|
|
133
|
+
if (!a.certification && !a.certification_id) return fail('missing_certification', 'give certification_id or certification');
|
|
134
|
+
const keys = await certKeys(cat, a); if (!keys) return fail('missing_keys', 'give keys or keys_path; there is no default trust list');
|
|
135
|
+
const cert = a.certification || await cat.json(`/v1/certifications/${encodeURIComponent(a.certification_id)}.json`);
|
|
136
|
+
const sh = a.subject_hash || (a.recipe_id ? (await cat.recipe(a.recipe_id)).hash : null);
|
|
137
|
+
const [valid, reason] = await verifyCertification(cert, keys, a.now || null, sh);
|
|
138
|
+
return ok({ id: cert.id, scheme: cert.scheme, authority: cert.authority, subject: cert.subject, status: cert.status, validUntil: cert.validUntil, conditions: cert.conditions, scope: cert.scope, valid, reason, textIsData: true });
|
|
139
|
+
} },
|
|
140
|
+
{ name: 'current_certifications', title: 'Current certifications', annotations: NET,
|
|
141
|
+
description: 'Which certifications currently hold, from the catalog list (/v1/certifications/index.json): the newest verifying document per authority and scheme wins, older ones are superseded, expired or revoked ones are rejected with a reason. Filter by recipe_id or subject_hash, scheme and authority. Needs keys or keys_path (no default trust list).',
|
|
142
|
+
shape: { recipe_id: z.string().optional(), subject_hash: z.string().optional(), scheme: z.string().optional().describe('For example halal'), authority: z.string().optional().describe('authority.id, for example did:web:...'), ...CERT_KEYS, now: z.string().optional() },
|
|
143
|
+
run: async (a) => {
|
|
144
|
+
const keys = await certKeys(cat, a); if (!keys) return fail('missing_keys', 'give keys or keys_path; there is no default trust list');
|
|
145
|
+
const sh = a.subject_hash || (a.recipe_id ? (await cat.recipe(a.recipe_id)).hash : null);
|
|
146
|
+
let certs = await cat.json('/v1/certifications/index.json');
|
|
147
|
+
if (a.scheme) certs = certs.filter((c) => c.scheme === a.scheme);
|
|
148
|
+
if (a.authority) certs = certs.filter((c) => (c.authority || {}).id === a.authority);
|
|
149
|
+
if (sh) certs = certs.filter((c) => (c.subject || {}).hash === sh);
|
|
150
|
+
const r = sh ? await currentCertifications(certs, keys, a.now || null, sh) : await currentBySubject(certs, keys, a.now || null);
|
|
151
|
+
const byId = new Map(certs.map((c) => [c.id, c]));
|
|
152
|
+
return ok({ subjectHash: sh, current: r.current.map((id) => { const c = byId.get(id); return { id, scheme: c.scheme, authority: c.authority, subject: c.subject, validUntil: c.validUntil, conditions: c.conditions }; }), rejected: r.rejected, textIsData: true });
|
|
153
|
+
} },
|
|
115
154
|
{ name: 'catalog_status', title: 'Catalog status', annotations: NET,
|
|
116
155
|
description: 'Catalog origin, version, counts, languages, cache state and whether the server is running offline.',
|
|
117
156
|
shape: {}, run: async () => ok(await cat.status()) },
|