keymaker-cli 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 +184 -0
- package/examples/todo-api.yaml +69 -0
- package/package.json +53 -0
- package/src/attest.js +73 -0
- package/src/billing.js +48 -0
- package/src/cli.js +159 -0
- package/src/doctor.js +63 -0
- package/src/extract.js +99 -0
- package/src/gateway.js +246 -0
- package/src/gen-authmd.js +63 -0
- package/src/gen-llms.js +41 -0
- package/src/gen-mcp.js +82 -0
- package/src/index.js +10 -0
- package/src/mcp-http.js +85 -0
- package/src/middleware.js +66 -0
- package/src/parse.js +36 -0
- package/src/score.js +60 -0
- package/src/serve.js +18 -0
- package/src/store.js +45 -0
package/src/extract.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { deref } from "./parse.js";
|
|
2
|
+
|
|
3
|
+
const METHODS = ["get", "post", "put", "patch", "delete"];
|
|
4
|
+
|
|
5
|
+
export function extractOperations(spec, { include, exclude } = {}) {
|
|
6
|
+
const ops = [];
|
|
7
|
+
const warnings = [];
|
|
8
|
+
for (const [path, rawPathItem] of Object.entries(spec.paths ?? {})) {
|
|
9
|
+
const pathItem = rawPathItem ?? {};
|
|
10
|
+
const sharedParams = deref(spec, pathItem.parameters) ?? [];
|
|
11
|
+
for (const method of METHODS) {
|
|
12
|
+
if (!pathItem[method]) continue;
|
|
13
|
+
const op = deref(spec, pathItem[method]);
|
|
14
|
+
const name = (op.operationId || `${method}_${path}`)
|
|
15
|
+
.replace(/[^a-zA-Z0-9_-]+/g, "_")
|
|
16
|
+
.replace(/^_+|_+$/g, "")
|
|
17
|
+
.slice(0, 64);
|
|
18
|
+
if (include?.length && !include.some((p) => name.toLowerCase().includes(p.toLowerCase()))) continue;
|
|
19
|
+
if (exclude?.length && exclude.some((p) => name.toLowerCase().includes(p.toLowerCase()))) continue;
|
|
20
|
+
|
|
21
|
+
const properties = {};
|
|
22
|
+
const required = new Set();
|
|
23
|
+
const paramLocs = {};
|
|
24
|
+
for (const p of [...sharedParams, ...(op.parameters ?? [])]) {
|
|
25
|
+
if (!p?.name) continue;
|
|
26
|
+
// Swagger 2.0: the whole request body arrives as a single in:"body" parameter
|
|
27
|
+
if (p.in === "body") {
|
|
28
|
+
const schema = p.schema ?? {};
|
|
29
|
+
if (schema.type === "object" && schema.properties) {
|
|
30
|
+
for (const [k, v] of Object.entries(schema.properties)) {
|
|
31
|
+
if (properties[k]) continue;
|
|
32
|
+
properties[k] = v;
|
|
33
|
+
paramLocs[k] = "body";
|
|
34
|
+
if ((schema.required ?? []).includes(k)) required.add(k);
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
properties.body = schema;
|
|
38
|
+
paramLocs.body = "body";
|
|
39
|
+
if (p.required) required.add("body");
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// Swagger 2.0 formData fields are body fields for our purposes
|
|
44
|
+
if (p.in === "formData") {
|
|
45
|
+
properties[p.name] = { type: p.type ?? "string", ...(p.description ? { description: p.description } : {}) };
|
|
46
|
+
paramLocs[p.name] = "body";
|
|
47
|
+
if (p.required) required.add(p.name);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
properties[p.name] = {
|
|
51
|
+
...(p.schema ?? { type: "string" }),
|
|
52
|
+
...(p.description || p.schema?.description
|
|
53
|
+
? { description: p.description ?? p.schema.description }
|
|
54
|
+
: {}),
|
|
55
|
+
};
|
|
56
|
+
if (p.required || p.in === "path") required.add(p.name);
|
|
57
|
+
paramLocs[p.name] = p.in;
|
|
58
|
+
if (!p.description && !p.schema?.description) {
|
|
59
|
+
warnings.push(`${name}: parameter "${p.name}" has no description`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const bodySchema = op.requestBody?.content?.["application/json"]?.schema;
|
|
64
|
+
if (bodySchema) {
|
|
65
|
+
if (bodySchema.type === "object" && bodySchema.properties) {
|
|
66
|
+
for (const [k, v] of Object.entries(bodySchema.properties)) {
|
|
67
|
+
if (properties[k]) continue;
|
|
68
|
+
properties[k] = v;
|
|
69
|
+
paramLocs[k] = "body";
|
|
70
|
+
if ((bodySchema.required ?? []).includes(k)) required.add(k);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
properties.body = bodySchema;
|
|
74
|
+
paramLocs.body = "body";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const description = [op.summary, op.description].filter(Boolean).join(" — ");
|
|
79
|
+
if (!description) {
|
|
80
|
+
warnings.push(`${name}: no summary or description — agents pick tools by docs; write one`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ops.push({
|
|
84
|
+
name,
|
|
85
|
+
description: description || `${method.toUpperCase()} ${path}`,
|
|
86
|
+
method: method.toUpperCase(),
|
|
87
|
+
path,
|
|
88
|
+
paramLocs,
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: "object",
|
|
91
|
+
properties,
|
|
92
|
+
...(required.size ? { required: [...required] } : {}),
|
|
93
|
+
},
|
|
94
|
+
tags: op.tags ?? [],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { ops, warnings };
|
|
99
|
+
}
|
package/src/gateway.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { verifyAttestation } from "./attest.js";
|
|
5
|
+
import { handleMcpRequest } from "./mcp-http.js";
|
|
6
|
+
import { createBilling } from "./billing.js";
|
|
7
|
+
import { hashKey, findByKey, readKeys, writeKeys } from "./store.js";
|
|
8
|
+
|
|
9
|
+
function readJson(req) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
let data = "";
|
|
12
|
+
req.on("data", (chunk) => (data += chunk));
|
|
13
|
+
req.on("end", () => {
|
|
14
|
+
try {
|
|
15
|
+
resolve(data ? JSON.parse(data) : {});
|
|
16
|
+
} catch {
|
|
17
|
+
resolve({});
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const GATEWAY_PATHS = new Set(["/auth.md", "/.well-known/auth.md", "/llms.txt", "/mcp"]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Mountable agent gateway: serves /auth.md, /.well-known/auth.md, /llms.txt,
|
|
27
|
+
* /agent-auth*, and /mcp from inside any node HTTP app.
|
|
28
|
+
*
|
|
29
|
+
* const gateway = await keymakerGateway({ dir: "./agent-ready" });
|
|
30
|
+
* // plain node: if (await gateway(req, res)) return;
|
|
31
|
+
* // express: app.use(gateway.express());
|
|
32
|
+
*
|
|
33
|
+
* Returns true if the request was handled.
|
|
34
|
+
*/
|
|
35
|
+
export async function keymakerGateway({ dir }) {
|
|
36
|
+
let config = {};
|
|
37
|
+
try {
|
|
38
|
+
config = JSON.parse(await readFile(join(dir, "signup.config.json"), "utf8"));
|
|
39
|
+
} catch {}
|
|
40
|
+
let mcpMeta = null;
|
|
41
|
+
try {
|
|
42
|
+
mcpMeta = JSON.parse(await readFile(join(dir, "tools.json"), "utf8"));
|
|
43
|
+
} catch {}
|
|
44
|
+
const billing = createBilling(config.billing);
|
|
45
|
+
|
|
46
|
+
const registrationLimit = config.registrations_per_minute_per_ip ?? 10;
|
|
47
|
+
const verifyLimit = config.verifies_per_minute_per_key ?? 120;
|
|
48
|
+
const regHits = new Map();
|
|
49
|
+
const verifyHits = new Map();
|
|
50
|
+
const allow = (map, id, limit) => {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const recent = (map.get(id) ?? []).filter((t) => t > now - 60_000);
|
|
53
|
+
if (recent.length >= limit) return false;
|
|
54
|
+
recent.push(now);
|
|
55
|
+
map.set(id, recent);
|
|
56
|
+
return true;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// keys.json on disk is the source of truth — the vendor's API process
|
|
60
|
+
// (keymakerAuth middleware) reads and writes it too. Raw keys are never
|
|
61
|
+
// stored; records hold a SHA-256 hash plus a display prefix.
|
|
62
|
+
const load = () => readKeys(dir);
|
|
63
|
+
const save = (keys) => writeKeys(dir, keys);
|
|
64
|
+
const safeEqual = (a, b) => {
|
|
65
|
+
const ba = Buffer.from(String(a ?? ""));
|
|
66
|
+
const bb = Buffer.from(String(b ?? ""));
|
|
67
|
+
return ba.length === bb.length && timingSafeEqual(ba, bb);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handler = async (req, res) => {
|
|
71
|
+
const url = new URL(req.url, "http://localhost");
|
|
72
|
+
const p = url.pathname;
|
|
73
|
+
if (!GATEWAY_PATHS.has(p) && !p.startsWith("/agent-auth")) return false;
|
|
74
|
+
|
|
75
|
+
const send = (code, body, type = "application/json") => {
|
|
76
|
+
res.writeHead(code, { "content-type": type });
|
|
77
|
+
res.end(type === "application/json" ? JSON.stringify(body, null, 2) : body);
|
|
78
|
+
};
|
|
79
|
+
try {
|
|
80
|
+
if (req.method === "GET" && (p === "/auth.md" || p === "/.well-known/auth.md")) {
|
|
81
|
+
send(200, await readFile(join(dir, "auth.md"), "utf8"), "text/markdown");
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (req.method === "GET" && p === "/llms.txt") {
|
|
85
|
+
send(200, await readFile(join(dir, "llms.txt"), "utf8"), "text/plain");
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (req.method === "POST" && p === "/agent-auth") {
|
|
89
|
+
if (!allow(regHits, req.socket.remoteAddress ?? "?", registrationLimit)) {
|
|
90
|
+
send(429, { error: "registration rate limit exceeded; retry in a minute" });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
const body = await readJson(req);
|
|
94
|
+
let attResult = null;
|
|
95
|
+
if (body.attestation) attResult = await verifyAttestation(body.attestation, config);
|
|
96
|
+
const verified = Boolean(attResult?.verified);
|
|
97
|
+
const keys = await load();
|
|
98
|
+
const keyId = `key_${randomBytes(6).toString("hex")}`;
|
|
99
|
+
const apiKey = `ak_${randomBytes(24).toString("hex")}`;
|
|
100
|
+
const record = {
|
|
101
|
+
key_id: keyId,
|
|
102
|
+
key_hash: hashKey(apiKey),
|
|
103
|
+
key_prefix: apiKey.slice(0, 10),
|
|
104
|
+
client_name: String(body.client_name ?? "unnamed-agent").slice(0, 100),
|
|
105
|
+
scopes: Array.isArray(body.scopes) && body.scopes.length ? body.scopes.map(String) : ["read"],
|
|
106
|
+
status: verified ? "agent_verified" : "unclaimed",
|
|
107
|
+
attestation_mode: attResult?.mode ?? null,
|
|
108
|
+
created_at: new Date().toISOString(),
|
|
109
|
+
expires_at: verified ? null : new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
|
110
|
+
claim_token: verified ? null : `ct_${randomBytes(12).toString("hex")}`,
|
|
111
|
+
usage: 0,
|
|
112
|
+
};
|
|
113
|
+
if (billing) {
|
|
114
|
+
try {
|
|
115
|
+
const b = await billing.onRegister(record, body);
|
|
116
|
+
if (b?.customer_id) record.billing_customer_id = b.customer_id;
|
|
117
|
+
} catch {}
|
|
118
|
+
}
|
|
119
|
+
keys[keyId] = record;
|
|
120
|
+
await save(keys);
|
|
121
|
+
const { claim_token, key_hash, ...pub } = record;
|
|
122
|
+
send(201, {
|
|
123
|
+
...pub,
|
|
124
|
+
api_key: apiKey,
|
|
125
|
+
key_storage: "hashed — this is the only time the full key is shown",
|
|
126
|
+
...(claim_token ? { claim_token, claim_endpoint: "/agent-auth/claim" } : {}),
|
|
127
|
+
...(attResult && !attResult.verified ? { attestation_error: attResult.reason } : {}),
|
|
128
|
+
});
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
if (req.method === "POST" && p === "/agent-auth/claim") {
|
|
132
|
+
const { claim_token } = await readJson(req);
|
|
133
|
+
const keys = await load();
|
|
134
|
+
const rec = Object.values(keys).find((k) => k.claim_token && k.claim_token === claim_token);
|
|
135
|
+
if (!rec) {
|
|
136
|
+
send(404, { error: "unknown claim token" });
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
rec.status = "claimed";
|
|
140
|
+
rec.expires_at = null;
|
|
141
|
+
rec.claim_token = null;
|
|
142
|
+
await save(keys);
|
|
143
|
+
send(200, { key_id: rec.key_id, status: rec.status });
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
if (req.method === "POST" && p === "/agent-auth/verify") {
|
|
147
|
+
const { api_key } = await readJson(req);
|
|
148
|
+
if (!allow(verifyHits, String(api_key ?? "?"), verifyLimit)) {
|
|
149
|
+
send(429, { error: "verify rate limit exceeded" });
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
const keys = await load();
|
|
153
|
+
const rec = findByKey(keys, api_key);
|
|
154
|
+
const expired = rec?.expires_at && Date.parse(rec.expires_at) < Date.now();
|
|
155
|
+
if (!rec || expired || rec.revoked) {
|
|
156
|
+
send(401, { valid: false });
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
rec.usage += 1;
|
|
160
|
+
await save(keys);
|
|
161
|
+
billing?.onMeter(rec).catch(() => {});
|
|
162
|
+
send(200, {
|
|
163
|
+
valid: true,
|
|
164
|
+
key_id: rec.key_id,
|
|
165
|
+
scopes: rec.scopes,
|
|
166
|
+
status: rec.status,
|
|
167
|
+
usage: rec.usage,
|
|
168
|
+
});
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (req.method === "GET" && p === "/agent-auth/keys") {
|
|
172
|
+
send(
|
|
173
|
+
200,
|
|
174
|
+
Object.values(await load()).map(({ key_hash, claim_token, ...rest }) => rest)
|
|
175
|
+
);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
if (req.method === "POST" && p === "/agent-auth/revoke") {
|
|
179
|
+
if (!config.admin_token || !safeEqual(req.headers["x-admin-token"], config.admin_token)) {
|
|
180
|
+
send(401, { error: "admin token required (x-admin-token header)" });
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
const { key_id } = await readJson(req);
|
|
184
|
+
const keys = await load();
|
|
185
|
+
if (!keys[key_id]) {
|
|
186
|
+
send(404, { error: "unknown key_id" });
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
keys[key_id].revoked = true;
|
|
190
|
+
await save(keys);
|
|
191
|
+
send(200, { key_id, revoked: true });
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
if (p === "/mcp") {
|
|
195
|
+
if (!mcpMeta) {
|
|
196
|
+
send(501, { error: "no tools.json in this directory; re-run keymaker generate" });
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
if (req.method !== "POST") {
|
|
200
|
+
send(405, { error: "POST only (stateless MCP)" });
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
const header = req.headers.authorization ?? "";
|
|
204
|
+
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
|
|
205
|
+
const keys = await load();
|
|
206
|
+
const rec = findByKey(keys, token);
|
|
207
|
+
const expired = rec?.expires_at && Date.parse(rec.expires_at) < Date.now();
|
|
208
|
+
if (!rec || expired || rec.revoked) {
|
|
209
|
+
send(401, { error: "valid bearer key required; register via POST /agent-auth (see /auth.md)" });
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
const body = await readJson(req);
|
|
213
|
+
await handleMcpRequest(req, res, body, {
|
|
214
|
+
meta: mcpMeta,
|
|
215
|
+
// api_key: the presented raw token — records only store its hash, and
|
|
216
|
+
// the proxy needs it to forward the agent's identity upstream.
|
|
217
|
+
agent: { ...rec, api_key: token },
|
|
218
|
+
meter: async () => {
|
|
219
|
+
const fresh = await load();
|
|
220
|
+
if (fresh[rec.key_id]) {
|
|
221
|
+
fresh[rec.key_id].usage += 1;
|
|
222
|
+
await save(fresh);
|
|
223
|
+
billing?.onMeter(fresh[rec.key_id]).catch(() => {});
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
send(404, { error: "not found", hint: "GET /auth.md for agent registration instructions" });
|
|
230
|
+
return true;
|
|
231
|
+
} catch (err) {
|
|
232
|
+
send(500, { error: String(err?.message ?? err) });
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
handler.express = () => (req, res, next) => {
|
|
238
|
+
handler(req, res)
|
|
239
|
+
.then((handled) => {
|
|
240
|
+
if (!handled) next();
|
|
241
|
+
})
|
|
242
|
+
.catch(next);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
return handler;
|
|
246
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export function renderAuthMd({ spec, serviceUrl }) {
|
|
2
|
+
const title = spec.info?.title ?? "API";
|
|
3
|
+
return `# Agent registration — ${title}
|
|
4
|
+
|
|
5
|
+
> Machine-readable instructions for AI agents to register and obtain scoped API keys with no human in the loop. Generated by keymaker. Pattern-compatible with auth.md (https://workos.com/auth-md); served at /auth.md and /.well-known/auth.md.
|
|
6
|
+
|
|
7
|
+
## Register
|
|
8
|
+
|
|
9
|
+
\`\`\`
|
|
10
|
+
POST ${serviceUrl}/agent-auth
|
|
11
|
+
Content-Type: application/json
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
"client_name": "<your agent's name>",
|
|
15
|
+
"scopes": ["read"],
|
|
16
|
+
"attestation": "<optional: ID-JAG token from your agent platform>"
|
|
17
|
+
}
|
|
18
|
+
\`\`\`
|
|
19
|
+
|
|
20
|
+
Response \`201\`:
|
|
21
|
+
|
|
22
|
+
\`\`\`json
|
|
23
|
+
{
|
|
24
|
+
"key_id": "key_…",
|
|
25
|
+
"api_key": "ak_…",
|
|
26
|
+
"status": "unclaimed | agent_verified",
|
|
27
|
+
"scopes": ["read"],
|
|
28
|
+
"expires_at": "<ISO timestamp or null>",
|
|
29
|
+
"claim_token": "ct_…",
|
|
30
|
+
"claim_url": "…"
|
|
31
|
+
}
|
|
32
|
+
\`\`\`
|
|
33
|
+
|
|
34
|
+
- **With a valid platform attestation** the key is issued as \`agent_verified\` and does not expire.
|
|
35
|
+
- **Without one** the key is temporary (60 minutes) — enough to try the API — and becomes permanent when a human claims it:
|
|
36
|
+
|
|
37
|
+
\`\`\`
|
|
38
|
+
POST ${serviceUrl}/agent-auth/claim
|
|
39
|
+
{ "claim_token": "ct_…" }
|
|
40
|
+
\`\`\`
|
|
41
|
+
|
|
42
|
+
Use the key as \`Authorization: Bearer ak_…\` on every API request.
|
|
43
|
+
|
|
44
|
+
## Scopes
|
|
45
|
+
|
|
46
|
+
- \`read\` — non-mutating endpoints (GET)
|
|
47
|
+
- \`write\` — mutating endpoints (POST/PUT/PATCH/DELETE)
|
|
48
|
+
|
|
49
|
+
## For the API's backend (verification + metering)
|
|
50
|
+
|
|
51
|
+
\`\`\`
|
|
52
|
+
POST ${serviceUrl}/agent-auth/verify
|
|
53
|
+
{ "api_key": "ak_…" }
|
|
54
|
+
\`\`\`
|
|
55
|
+
|
|
56
|
+
Returns \`{ "valid": true, "scopes": […], "status": "…", "usage": <request count> }\` and increments the usage meter. \`401\` if unknown or expired.
|
|
57
|
+
|
|
58
|
+
## Discovering this API
|
|
59
|
+
|
|
60
|
+
- [llms.txt](${serviceUrl}/llms.txt) — agent-readable API overview
|
|
61
|
+
- MCP server available (stdio): ask the vendor for connection details, or run the generated \`mcp-server.mjs\`
|
|
62
|
+
`;
|
|
63
|
+
}
|
package/src/gen-llms.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function renderLlmsTxt({ spec, ops, baseUrl, serviceUrl }) {
|
|
2
|
+
const title = spec.info?.title ?? "API";
|
|
3
|
+
const lines = [`# ${title}`, ""];
|
|
4
|
+
if (spec.info?.description) {
|
|
5
|
+
lines.push(`> ${spec.info.description.trim().split("\n")[0]}`, "");
|
|
6
|
+
}
|
|
7
|
+
lines.push(`Base URL: ${baseUrl}`, "");
|
|
8
|
+
|
|
9
|
+
const schemes = Object.entries(spec.components?.securitySchemes ?? {});
|
|
10
|
+
if (schemes.length) {
|
|
11
|
+
lines.push("## Authentication", "");
|
|
12
|
+
for (const [name, s] of schemes) {
|
|
13
|
+
lines.push(
|
|
14
|
+
`- ${name}: ${s.type}${s.scheme ? ` (${s.scheme})` : ""}${s.description ? ` — ${s.description}` : ""}`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
lines.push("");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
lines.push(
|
|
21
|
+
"## Agent signup",
|
|
22
|
+
"",
|
|
23
|
+
`- [auth.md](${serviceUrl}/auth.md): Agents can register and obtain a scoped API key programmatically — no human required. POST ${serviceUrl}/agent-auth`,
|
|
24
|
+
""
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const byTag = new Map();
|
|
28
|
+
for (const op of ops) {
|
|
29
|
+
const tag = op.tags[0] ?? "Endpoints";
|
|
30
|
+
if (!byTag.has(tag)) byTag.set(tag, []);
|
|
31
|
+
byTag.get(tag).push(op);
|
|
32
|
+
}
|
|
33
|
+
for (const [tag, list] of byTag) {
|
|
34
|
+
lines.push(`## ${tag}`, "");
|
|
35
|
+
for (const op of list) {
|
|
36
|
+
lines.push(`- ${op.method} ${op.path} (${op.name}): ${op.description}`);
|
|
37
|
+
}
|
|
38
|
+
lines.push("");
|
|
39
|
+
}
|
|
40
|
+
return lines.join("\n");
|
|
41
|
+
}
|
package/src/gen-mcp.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export function toTools(ops) {
|
|
2
|
+
return ops.map(({ name, description, method, path, paramLocs, inputSchema }) => ({
|
|
3
|
+
name,
|
|
4
|
+
description,
|
|
5
|
+
method,
|
|
6
|
+
path,
|
|
7
|
+
paramLocs,
|
|
8
|
+
inputSchema,
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function renderMcpServer({ title, version, baseUrl, ops }) {
|
|
13
|
+
const tools = toTools(ops);
|
|
14
|
+
|
|
15
|
+
return `#!/usr/bin/env node
|
|
16
|
+
// Generated by keymaker — MCP server for ${title}
|
|
17
|
+
// Run: node mcp-server.mjs (stdio transport; point any MCP client at it)
|
|
18
|
+
// Env: API_BASE_URL (default ${baseUrl}), API_KEY (sent as Bearer token)
|
|
19
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
20
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
21
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
22
|
+
|
|
23
|
+
const BASE_URL = (process.env.API_BASE_URL ?? ${JSON.stringify(baseUrl)}).replace(/\\/$/, "");
|
|
24
|
+
const API_KEY = process.env.API_KEY ?? "";
|
|
25
|
+
|
|
26
|
+
const TOOLS = ${JSON.stringify(tools, null, 2)};
|
|
27
|
+
|
|
28
|
+
const server = new Server(
|
|
29
|
+
{ name: ${JSON.stringify(`${title} (agent-ready)`)}, version: ${JSON.stringify(version)} },
|
|
30
|
+
{ capabilities: { tools: {} } }
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
34
|
+
tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
38
|
+
const tool = TOOLS.find((t) => t.name === request.params.name);
|
|
39
|
+
if (!tool) {
|
|
40
|
+
return { content: [{ type: "text", text: \`Unknown tool: \${request.params.name}\` }], isError: true };
|
|
41
|
+
}
|
|
42
|
+
const args = request.params.arguments ?? {};
|
|
43
|
+
let path = tool.path;
|
|
44
|
+
const query = new URLSearchParams();
|
|
45
|
+
const headers = { "content-type": "application/json" };
|
|
46
|
+
if (API_KEY) headers.authorization = \`Bearer \${API_KEY}\`;
|
|
47
|
+
const body = {};
|
|
48
|
+
let hasBody = false;
|
|
49
|
+
for (const [key, value] of Object.entries(args)) {
|
|
50
|
+
if (value === undefined || value === null) continue;
|
|
51
|
+
const loc = tool.paramLocs[key];
|
|
52
|
+
if (loc === "path") path = path.replace(\`{\${key}}\`, encodeURIComponent(String(value)));
|
|
53
|
+
else if (loc === "query") query.set(key, String(value));
|
|
54
|
+
else if (loc === "header") headers[key.toLowerCase()] = String(value);
|
|
55
|
+
else {
|
|
56
|
+
if (key === "body" && typeof value === "object") Object.assign(body, value);
|
|
57
|
+
else body[key] = value;
|
|
58
|
+
hasBody = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const url = \`\${BASE_URL}\${path}\${[...query].length ? \`?\${query}\` : ""}\`;
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(url, {
|
|
64
|
+
method: tool.method,
|
|
65
|
+
headers,
|
|
66
|
+
...(hasBody ? { body: JSON.stringify(body) } : {}),
|
|
67
|
+
});
|
|
68
|
+
const text = await res.text();
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: "text", text: \`HTTP \${res.status} \${tool.method} \${url}\\n\${text.slice(0, 20000)}\` }],
|
|
71
|
+
isError: res.status >= 400,
|
|
72
|
+
};
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { content: [{ type: "text", text: \`Request failed: \${err.message}\` }], isError: true };
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const transport = new StdioServerTransport();
|
|
79
|
+
await server.connect(transport);
|
|
80
|
+
console.error(\`[keymaker] MCP server ready — \${TOOLS.length} tools proxying \${BASE_URL}\`);
|
|
81
|
+
`;
|
|
82
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { startSignupServer } from "./serve.js";
|
|
2
|
+
export { keymakerGateway } from "./gateway.js";
|
|
3
|
+
export { keymakerAuth } from "./middleware.js";
|
|
4
|
+
export { createBilling } from "./billing.js";
|
|
5
|
+
export { verifyAttestation } from "./attest.js";
|
|
6
|
+
export { loadSpec, deref } from "./parse.js";
|
|
7
|
+
export { hashKey, findByKey, readKeys, writeKeys } from "./store.js";
|
|
8
|
+
export { extractOperations } from "./extract.js";
|
|
9
|
+
export { scoreSpec } from "./score.js";
|
|
10
|
+
export { checkSite } from "./doctor.js";
|
package/src/mcp-http.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
|
|
5
|
+
const READ_METHODS = new Set(["GET", "HEAD"]);
|
|
6
|
+
|
|
7
|
+
function allowedTools(tools, scopes) {
|
|
8
|
+
const canWrite = scopes.includes("write");
|
|
9
|
+
const canRead = canWrite || scopes.includes("read");
|
|
10
|
+
return tools.filter((t) => (READ_METHODS.has(t.method) ? canRead : canWrite));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function proxyCall(tool, args, baseUrl, agentKey) {
|
|
14
|
+
let path = tool.path;
|
|
15
|
+
const query = new URLSearchParams();
|
|
16
|
+
// Forward the agent's own key upstream so the vendor's API (e.g. keymakerAuth
|
|
17
|
+
// middleware) sees the same identity that authenticated at /mcp.
|
|
18
|
+
const headers = { "content-type": "application/json", authorization: `Bearer ${agentKey}` };
|
|
19
|
+
const body = {};
|
|
20
|
+
let hasBody = false;
|
|
21
|
+
for (const [key, value] of Object.entries(args ?? {})) {
|
|
22
|
+
if (value === undefined || value === null) continue;
|
|
23
|
+
const loc = tool.paramLocs[key];
|
|
24
|
+
if (loc === "path") path = path.replace(`{${key}}`, encodeURIComponent(String(value)));
|
|
25
|
+
else if (loc === "query") query.set(key, String(value));
|
|
26
|
+
else if (loc === "header") headers[key.toLowerCase()] = String(value);
|
|
27
|
+
else {
|
|
28
|
+
if (key === "body" && typeof value === "object") Object.assign(body, value);
|
|
29
|
+
else body[key] = value;
|
|
30
|
+
hasBody = true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const url = `${baseUrl}${path}${[...query].length ? `?${query}` : ""}`;
|
|
34
|
+
const res = await fetch(url, {
|
|
35
|
+
method: tool.method,
|
|
36
|
+
headers,
|
|
37
|
+
...(hasBody ? { body: JSON.stringify(body) } : {}),
|
|
38
|
+
});
|
|
39
|
+
const text = await res.text();
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: "text", text: `HTTP ${res.status} ${tool.method} ${url}\n${text.slice(0, 20000)}` }],
|
|
42
|
+
isError: res.status >= 400,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Handle one HTTP request to /mcp (stateless Streamable HTTP).
|
|
48
|
+
* `agent` is the authenticated key record; tool visibility and calls are
|
|
49
|
+
* scope-filtered, and `meter()` is invoked on every successful tool call.
|
|
50
|
+
*/
|
|
51
|
+
export async function handleMcpRequest(req, res, body, { meta, agent, meter }) {
|
|
52
|
+
const visible = allowedTools(meta.tools, agent.scopes);
|
|
53
|
+
const server = new Server(
|
|
54
|
+
{ name: `${meta.title} (agent-ready)`, version: meta.version ?? "0.0.0" },
|
|
55
|
+
{ capabilities: { tools: {} } }
|
|
56
|
+
);
|
|
57
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
58
|
+
tools: visible.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
59
|
+
}));
|
|
60
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
61
|
+
const tool = visible.find((t) => t.name === request.params.name);
|
|
62
|
+
if (!tool) {
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: "text", text: `Unknown tool or insufficient scope: ${request.params.name}` }],
|
|
65
|
+
isError: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const result = await proxyCall(tool, request.params.arguments, meta.baseUrl, agent.api_key);
|
|
69
|
+
// When the upstream API is keymaker-protected, its middleware already meters
|
|
70
|
+
// this key; meter at the gateway only for hosted-only setups that opt in.
|
|
71
|
+
if (!result.isError && meta.meter_at_gateway) await meter();
|
|
72
|
+
return result;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const transport = new StreamableHTTPServerTransport({
|
|
76
|
+
sessionIdGenerator: undefined,
|
|
77
|
+
enableJsonResponse: true,
|
|
78
|
+
});
|
|
79
|
+
res.on("close", () => {
|
|
80
|
+
transport.close();
|
|
81
|
+
server.close();
|
|
82
|
+
});
|
|
83
|
+
await server.connect(transport);
|
|
84
|
+
await transport.handleRequest(req, res, body);
|
|
85
|
+
}
|