@12-apps/mcp 1.0.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.
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { generateTools, type OpenApiDocument } from "./generate";
4
+
5
+ const doc: OpenApiDocument = {
6
+ paths: {
7
+ "/products/{id}": {
8
+ get: {
9
+ operationId: "getProduct",
10
+ summary: "Fetch a product",
11
+ parameters: [
12
+ { name: "id", in: "path", required: true, schema: { type: "string" } },
13
+ { name: "include", in: "query", required: false, schema: { type: "string" } },
14
+ ],
15
+ responses: {
16
+ "200": { content: { "application/json": { schema: { type: "object" } } } },
17
+ },
18
+ security: [{ bearerAuth: [] }],
19
+ },
20
+ },
21
+ "/products": {
22
+ post: {
23
+ summary: "Create a product",
24
+ requestBody: {
25
+ required: true,
26
+ content: {
27
+ "application/json": {
28
+ schema: {
29
+ type: "object",
30
+ required: ["name"],
31
+ properties: {
32
+ name: { type: "string" },
33
+ priceCents: { type: "integer" },
34
+ },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ },
40
+ get: {
41
+ operationId: "listProducts",
42
+ tags: ["internal"],
43
+ },
44
+ },
45
+ },
46
+ };
47
+
48
+ describe("generateTools", () => {
49
+ it("maps a GET operation with path + query params", () => {
50
+ const tools = generateTools(doc);
51
+ const get = tools.find((t) => t.name === "getProduct");
52
+ expect(get).toBeDefined();
53
+ expect(get?.method).toBe("GET");
54
+ expect(get?.path).toBe("/products/{id}");
55
+ expect(get?.mutating).toBe(false);
56
+ expect(get?.description).toBe("Fetch a product");
57
+ expect(get?.security).toEqual(["bearerAuth"]);
58
+ // path param is required even though only query is marked optional
59
+ expect(get?.inputSchema.required).toEqual(["id"]);
60
+ expect(Object.keys(get?.inputSchema.properties as object)).toEqual(["id", "include"]);
61
+ expect(get?.parameters.map((p) => `${p.in}:${p.name}`)).toEqual(["path:id", "query:include"]);
62
+ expect(get?.bodyProps).toEqual([]);
63
+ expect(get?.outputSchema).toEqual({ type: "object" });
64
+ });
65
+
66
+ it("flattens an object request body and records bodyProps + required", () => {
67
+ const post = generateTools(doc).find((t) => t.path === "/products" && t.method === "POST");
68
+ expect(post?.mutating).toBe(true);
69
+ expect(post?.name).toBe("post_products"); // no operationId -> slug
70
+ expect(post?.bodyProps).toEqual(["name", "priceCents"]);
71
+ expect(post?.bodyIsWhole).toBe(false);
72
+ expect(post?.inputSchema.required).toEqual(["name"]);
73
+ expect(Object.keys(post?.inputSchema.properties as object)).toEqual(["name", "priceCents"]);
74
+ });
75
+
76
+ it("excludes operations by tag", () => {
77
+ const names = generateTools(doc, { excludeTags: ["internal"] }).map((t) => t.name);
78
+ expect(names).not.toContain("listProducts");
79
+ });
80
+
81
+ it("filters by method", () => {
82
+ const methods = generateTools(doc, { includeMethods: ["get"] }).map((t) => t.method);
83
+ expect(new Set(methods)).toEqual(new Set(["GET"]));
84
+ });
85
+
86
+ it("is deterministic across calls", () => {
87
+ expect(generateTools(doc)).toEqual(generateTools(doc));
88
+ });
89
+
90
+ it("exposes a non-object body as a single verbatim property", () => {
91
+ const wholeBodyDoc: OpenApiDocument = {
92
+ paths: {
93
+ "/raw": {
94
+ post: {
95
+ operationId: "postRaw",
96
+ requestBody: { required: true, content: { "application/json": { schema: { type: "array" } } } },
97
+ },
98
+ },
99
+ },
100
+ };
101
+ const tool = generateTools(wholeBodyDoc)[0];
102
+ expect(tool.bodyIsWhole).toBe(true);
103
+ expect(tool.bodyProps).toEqual([]);
104
+ expect(tool.inputSchema.required).toEqual(["body"]);
105
+ expect((tool.inputSchema.properties as Record<string, unknown>).body).toEqual({ type: "array" });
106
+ });
107
+ });
@@ -0,0 +1,227 @@
1
+ import type {
2
+ GeneratedTool,
3
+ GenerateOptions,
4
+ JsonSchema,
5
+ ParameterLocation,
6
+ ToolParameter,
7
+ } from "../types";
8
+
9
+ /**
10
+ * Minimal structural view of the OpenAPI 3.x document we consume. We intentionally
11
+ * model only the subset the generator reads; unknown fields are ignored and any
12
+ * `$ref` in a leaf schema is forwarded opaquely (the document is expected to be
13
+ * dereferenced by the loader for anything we need to introspect — request-body
14
+ * object properties in particular).
15
+ */
16
+ export interface OpenApiOperation {
17
+ operationId?: string;
18
+ summary?: string;
19
+ description?: string;
20
+ tags?: string[];
21
+ parameters?: OpenApiParameter[];
22
+ requestBody?: OpenApiRequestBody;
23
+ responses?: Record<string, OpenApiResponse>;
24
+ security?: Array<Record<string, string[]>>;
25
+ }
26
+
27
+ export interface OpenApiParameter {
28
+ name: string;
29
+ in: string;
30
+ required?: boolean;
31
+ schema?: JsonSchema;
32
+ }
33
+
34
+ export interface OpenApiRequestBody {
35
+ required?: boolean;
36
+ content?: Record<string, { schema?: JsonSchema }>;
37
+ }
38
+
39
+ export interface OpenApiResponse {
40
+ content?: Record<string, { schema?: JsonSchema }>;
41
+ }
42
+
43
+ export interface OpenApiDocument {
44
+ paths?: Record<string, Record<string, OpenApiOperation>>;
45
+ security?: Array<Record<string, string[]>>;
46
+ }
47
+
48
+ const HTTP_METHODS = ["get", "put", "post", "delete", "patch", "options", "head"] as const;
49
+ const MUTATING = new Set(["post", "put", "patch", "delete"]);
50
+ const PARAM_LOCATIONS = new Set<ParameterLocation>(["path", "query", "header"]);
51
+
52
+ /** JSON body is the only content type the generic dispatcher understands. */
53
+ const JSON_CONTENT = "application/json";
54
+
55
+ function slugify(method: string, path: string): string {
56
+ const cleaned = path
57
+ .replace(/[{}]/g, "")
58
+ .replace(/[^a-zA-Z0-9]+/g, "_")
59
+ .replace(/^_+|_+$/g, "")
60
+ .toLowerCase();
61
+ return `${method.toLowerCase()}_${cleaned || "root"}`;
62
+ }
63
+
64
+ function securityNames(op: OpenApiOperation, doc: OpenApiDocument): string[] {
65
+ const requirements = op.security ?? doc.security ?? [];
66
+ return [...new Set(requirements.flatMap((requirement) => Object.keys(requirement)))];
67
+ }
68
+
69
+ function bodySchema(op: OpenApiOperation): JsonSchema | undefined {
70
+ return op.requestBody?.content?.[JSON_CONTENT]?.schema;
71
+ }
72
+
73
+ function responseSchema(op: OpenApiOperation): JsonSchema | undefined {
74
+ const responses = op.responses ?? {};
75
+ const code = ["200", "201", "2XX", "default"].find((c) => responses[c]?.content?.[JSON_CONTENT]?.schema);
76
+ return code ? responses[code]?.content?.[JSON_CONTENT]?.schema : undefined;
77
+ }
78
+
79
+ /** Path params are always required regardless of how the spec marks them. */
80
+ function paramRequired(location: ParameterLocation, raw: OpenApiParameter): boolean {
81
+ return location === "path" ? true : Boolean(raw.required);
82
+ }
83
+
84
+ /** OpenAPI path/query/header params (body is handled separately), in declaration order. */
85
+ function toolParameters(op: OpenApiOperation): ToolParameter[] {
86
+ return (op.parameters ?? [])
87
+ .filter((raw) => PARAM_LOCATIONS.has(raw.in as ParameterLocation))
88
+ .map((raw) => {
89
+ const location = raw.in as ParameterLocation;
90
+ return {
91
+ name: raw.name,
92
+ in: location,
93
+ required: paramRequired(location, raw),
94
+ schema: raw.schema ?? { type: "string" },
95
+ };
96
+ });
97
+ }
98
+
99
+ interface BodyContribution {
100
+ properties: Record<string, JsonSchema>;
101
+ bodyProps: string[];
102
+ requiredProps: string[];
103
+ bodyIsWhole: boolean;
104
+ }
105
+
106
+ /**
107
+ * How the request body contributes to the flat input schema: an object body is
108
+ * flattened (its property names recorded so the dispatcher routes them back to
109
+ * the body); a non-object/opaque body is exposed as a single verbatim `body`
110
+ * property.
111
+ */
112
+ function bodyContribution(op: OpenApiOperation): BodyContribution {
113
+ const body = bodySchema(op);
114
+ if (!body) return { properties: {}, bodyProps: [], requiredProps: [], bodyIsWhole: false };
115
+
116
+ const propSchemas = body.properties as Record<string, JsonSchema> | undefined;
117
+ if (body.type === "object" && propSchemas) {
118
+ const bodyRequired = new Set(Array.isArray(body.required) ? (body.required as string[]) : []);
119
+ const bodyProps = Object.keys(propSchemas);
120
+ return {
121
+ properties: propSchemas,
122
+ bodyProps,
123
+ requiredProps: bodyProps.filter((key) => bodyRequired.has(key)),
124
+ bodyIsWhole: false,
125
+ };
126
+ }
127
+
128
+ return {
129
+ properties: { body },
130
+ bodyProps: [],
131
+ requiredProps: op.requestBody?.required ? ["body"] : [],
132
+ bodyIsWhole: true,
133
+ };
134
+ }
135
+
136
+ interface BuiltInput {
137
+ inputSchema: JsonSchema;
138
+ parameters: ToolParameter[];
139
+ bodyProps: string[];
140
+ bodyIsWhole: boolean;
141
+ }
142
+
143
+ /**
144
+ * Build the agent-facing input schema and the routing metadata for one operation.
145
+ * Parameters and (flattened) body properties share one flat top level.
146
+ */
147
+ function buildInput(op: OpenApiOperation): BuiltInput {
148
+ const parameters = toolParameters(op);
149
+ const paramProps: Record<string, JsonSchema> = {};
150
+ const required: string[] = [];
151
+ parameters.forEach((param) => {
152
+ paramProps[param.name] = param.schema;
153
+ if (param.required) required.push(param.name);
154
+ });
155
+
156
+ const body = bodyContribution(op);
157
+ const properties = { ...paramProps, ...body.properties };
158
+ const allRequired = [...required, ...body.requiredProps];
159
+ const inputSchema: JsonSchema = {
160
+ type: "object",
161
+ additionalProperties: false,
162
+ properties,
163
+ ...(allRequired.length ? { required: allRequired } : {}),
164
+ };
165
+ return { inputSchema, parameters, bodyProps: body.bodyProps, bodyIsWhole: body.bodyIsWhole };
166
+ }
167
+
168
+ /** The declared operations of a path item, as (method, operation) pairs. */
169
+ function operationEntries(
170
+ pathItem: Record<string, OpenApiOperation>,
171
+ ): Array<[string, OpenApiOperation]> {
172
+ return HTTP_METHODS.filter((method) => pathItem[method]).map((method) => [
173
+ method,
174
+ pathItem[method] as OpenApiOperation,
175
+ ]);
176
+ }
177
+
178
+ function buildTool(
179
+ method: string,
180
+ path: string,
181
+ op: OpenApiOperation,
182
+ doc: OpenApiDocument,
183
+ seenNames: Set<string>,
184
+ ): GeneratedTool {
185
+ const candidate = op.operationId ?? slugify(method, path);
186
+ const name = seenNames.has(candidate) ? slugify(method, path) : candidate;
187
+ seenNames.add(name);
188
+
189
+ const { inputSchema, parameters, bodyProps, bodyIsWhole } = buildInput(op);
190
+ return {
191
+ name,
192
+ description: op.summary ?? op.description ?? `${method.toUpperCase()} ${path}`,
193
+ method: method.toUpperCase(),
194
+ path,
195
+ inputSchema,
196
+ outputSchema: responseSchema(op),
197
+ parameters,
198
+ bodyProps,
199
+ bodyIsWhole,
200
+ mutating: MUTATING.has(method),
201
+ security: securityNames(op, doc),
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Generate one {@link GeneratedTool} per OpenAPI operation. Deterministic: the
207
+ * same document always yields the same tools in path/method declaration order,
208
+ * which is what makes the drift gate (`mcp:check`) a stable diff.
209
+ */
210
+ export function generateTools(
211
+ doc: OpenApiDocument,
212
+ options: GenerateOptions = {},
213
+ ): GeneratedTool[] {
214
+ const includeMethods = options.includeMethods
215
+ ? new Set(options.includeMethods.map((m) => m.toLowerCase()))
216
+ : null;
217
+ const excludeTags = new Set(options.excludeTags ?? []);
218
+ const seenNames = new Set<string>();
219
+
220
+ return Object.entries(doc.paths ?? {}).flatMap(([path, pathItem]) =>
221
+ operationEntries(pathItem)
222
+ .filter(([method]) => !includeMethods || includeMethods.has(method))
223
+ .filter(([, op]) => !op.tags?.some((tag) => excludeTags.has(tag)))
224
+ .filter(([method]) => !options.filter || options.filter(method, path))
225
+ .map(([method, op]) => buildTool(method, path, op, doc, seenNames)),
226
+ );
227
+ }
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { inlineSchemaRefs, UnsupportedSchemaError } from "./refs";
4
+
5
+ describe("inlineSchemaRefs", () => {
6
+ it("returns a flat schema structurally unchanged (no-op)", () => {
7
+ const flat = {
8
+ $schema: "https://json-schema.org/draft/2020-12/schema",
9
+ type: "object",
10
+ properties: { data: { type: "array", items: { type: "string" } } },
11
+ required: ["data"],
12
+ additionalProperties: false,
13
+ };
14
+ expect(inlineSchemaRefs(flat)).toEqual(flat);
15
+ });
16
+
17
+ it("inlines a $defs reference and drops the $defs container", () => {
18
+ const schema = {
19
+ type: "object",
20
+ properties: { card: { $ref: "#/$defs/Card" } },
21
+ $defs: {
22
+ Card: { type: "object", properties: { last4: { type: "string" } } },
23
+ },
24
+ };
25
+ expect(inlineSchemaRefs(schema)).toEqual({
26
+ type: "object",
27
+ properties: { card: { type: "object", properties: { last4: { type: "string" } } } },
28
+ });
29
+ });
30
+
31
+ it("resolves diamond reuse (same def on sibling branches) without a cycle error", () => {
32
+ const schema = {
33
+ type: "object",
34
+ properties: {
35
+ a: { $ref: "#/$defs/Widget" },
36
+ b: { $ref: "#/$defs/Widget" },
37
+ },
38
+ $defs: { Widget: { type: "object", properties: { x: { type: "string" } } } },
39
+ };
40
+ const inlined = inlineSchemaRefs(schema) as {
41
+ properties: { a: unknown; b: unknown };
42
+ };
43
+ expect(inlined.properties.a).toEqual({ type: "object", properties: { x: { type: "string" } } });
44
+ expect(inlined.properties.a).toEqual(inlined.properties.b);
45
+ });
46
+
47
+ it("supports #/definitions and #/components/schemas pointers", () => {
48
+ const legacy = {
49
+ type: "object",
50
+ properties: { v: { $ref: "#/definitions/V" } },
51
+ definitions: { V: { type: "number" } },
52
+ };
53
+ expect(inlineSchemaRefs(legacy)).toEqual({
54
+ type: "object",
55
+ properties: { v: { type: "number" } },
56
+ });
57
+ });
58
+
59
+ it("throws on a recursive schema", () => {
60
+ const recursive = {
61
+ type: "object",
62
+ properties: { child: { $ref: "#/$defs/Node" } },
63
+ $defs: {
64
+ Node: { type: "object", properties: { next: { $ref: "#/$defs/Node" } } },
65
+ },
66
+ };
67
+ expect(() => inlineSchemaRefs(recursive)).toThrow(UnsupportedSchemaError);
68
+ expect(() => inlineSchemaRefs(recursive)).toThrow(/Recursive schema not supported: Node/);
69
+ });
70
+
71
+ it("throws on an unresolved reference", () => {
72
+ expect(() => inlineSchemaRefs({ $ref: "#/$defs/Missing" })).toThrow(/Unresolved \$ref/);
73
+ });
74
+
75
+ it("throws on an unsupported (external) pointer", () => {
76
+ expect(() => inlineSchemaRefs({ $ref: "https://example.com/schema.json" })).toThrow(
77
+ /Unsupported \$ref pointer/,
78
+ );
79
+ });
80
+ });
@@ -0,0 +1,84 @@
1
+ import type { JsonSchema } from "../types";
2
+
3
+ /**
4
+ * Raised when a JSON Schema cannot be turned into a flat, self-contained tool
5
+ * input — an unresolvable `$ref`, an unsupported pointer, or a recursive schema.
6
+ * The MCP tool surface is deliberately finite and flat (it is handed to an LLM
7
+ * and committed to the drift manifest), so recursion is rejected rather than
8
+ * silently truncated.
9
+ */
10
+ export class UnsupportedSchemaError extends Error {
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = "UnsupportedSchemaError";
14
+ }
15
+ }
16
+
17
+ /** Local-definition containers a `$ref` may point into, in resolution order. */
18
+ const REF_PREFIXES = ["#/$defs/", "#/definitions/", "#/components/schemas/"] as const;
19
+ const DEF_CONTAINERS = ["$defs", "definitions"] as const;
20
+
21
+ /** The definition name a supported local pointer targets, or `null` if unsupported. */
22
+ function refName(ref: string): string | null {
23
+ const prefix = REF_PREFIXES.find((candidate) => ref.startsWith(candidate));
24
+ return prefix ? decodeURIComponent(ref.slice(prefix.length)) : null;
25
+ }
26
+
27
+ /** Collect the `$defs`/`definitions` maps hoisted onto a schema root into one lookup. */
28
+ function collectDefs(root: JsonSchema): Record<string, JsonSchema> {
29
+ const defs: Record<string, JsonSchema> = {};
30
+ DEF_CONTAINERS.forEach((key) => {
31
+ const container = root[key];
32
+ if (container && typeof container === "object") {
33
+ Object.assign(defs, container as Record<string, JsonSchema>);
34
+ }
35
+ });
36
+ return defs;
37
+ }
38
+
39
+ function inlineRef(
40
+ ref: string,
41
+ defs: Record<string, JsonSchema>,
42
+ active: Set<string>,
43
+ ): unknown {
44
+ const name = refName(ref);
45
+ if (name === null) throw new UnsupportedSchemaError(`Unsupported $ref pointer: ${ref}`);
46
+ const target = defs[name];
47
+ if (!target) throw new UnsupportedSchemaError(`Unresolved $ref: ${ref}`);
48
+ if (active.has(name)) throw new UnsupportedSchemaError(`Recursive schema not supported: ${name}`);
49
+ active.add(name);
50
+ const resolved = walk(target, defs, active);
51
+ active.delete(name);
52
+ return resolved;
53
+ }
54
+
55
+ /** Deep-copy `node`, inlining every `$ref` and stripping definition containers. */
56
+ function walk(node: unknown, defs: Record<string, JsonSchema>, active: Set<string>): unknown {
57
+ if (Array.isArray(node)) return node.map((item) => walk(item, defs, active));
58
+ if (!node || typeof node !== "object") return node;
59
+
60
+ const obj = node as Record<string, unknown>;
61
+ if (typeof obj.$ref === "string") return inlineRef(obj.$ref, defs, active);
62
+
63
+ const out: Record<string, unknown> = {};
64
+ Object.entries(obj).forEach(([key, value]) => {
65
+ if (!DEF_CONTAINERS.includes(key as (typeof DEF_CONTAINERS)[number])) {
66
+ out[key] = walk(value, defs, active);
67
+ }
68
+ });
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * Inline every local `$ref` in a JSON Schema and drop the now-empty `$defs`/
74
+ * `definitions` containers, yielding a flat, self-contained schema. Diamond reuse
75
+ * (the same definition referenced by sibling branches) is fine; only a true cycle
76
+ * — a definition that references itself up the resolution stack — is rejected.
77
+ *
78
+ * A schema with no `$ref`/definitions is returned structurally unchanged, so
79
+ * inlining an already-flat spec is a no-op (the drift gate stays a stable diff).
80
+ */
81
+ export function inlineSchemaRefs(schema: JsonSchema): JsonSchema {
82
+ const defs = collectDefs(schema);
83
+ return walk(schema, defs, new Set<string>()) as JsonSchema;
84
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { buildManifest, serializeManifest } from "./manifest";
4
+ import type { GeneratedTool } from "../types";
5
+
6
+ function tool(name: string): GeneratedTool {
7
+ return {
8
+ name,
9
+ description: `desc ${name}`,
10
+ method: "GET",
11
+ path: `/${name}`,
12
+ inputSchema: { type: "object", properties: {} },
13
+ parameters: [],
14
+ bodyProps: [],
15
+ bodyIsWhole: false,
16
+ mutating: false,
17
+ security: [],
18
+ };
19
+ }
20
+
21
+ describe("buildManifest", () => {
22
+ it("sorts tools by name regardless of input order", () => {
23
+ const manifest = buildManifest([tool("charlie"), tool("alpha"), tool("bravo")], {
24
+ version: 1,
25
+ source: "test",
26
+ });
27
+ expect(manifest.tools.map((t) => t.name)).toEqual(["alpha", "bravo", "charlie"]);
28
+ expect(manifest.version).toBe(1);
29
+ expect(manifest.source).toBe("test");
30
+ });
31
+ });
32
+
33
+ describe("serializeManifest", () => {
34
+ it("is deterministic and ends with a trailing newline", () => {
35
+ const a = serializeManifest(buildManifest([tool("b"), tool("a")], { version: 1, source: "s" }));
36
+ const b = serializeManifest(buildManifest([tool("a"), tool("b")], { version: 1, source: "s" }));
37
+ expect(a).toBe(b);
38
+ expect(a.endsWith("\n")).toBe(true);
39
+ });
40
+
41
+ it("deep-sorts object keys so key order never churns the diff", () => {
42
+ const t1 = tool("x");
43
+ const t2: GeneratedTool = { ...tool("x") };
44
+ // Same data, different key insertion order in inputSchema.
45
+ t1.inputSchema = { type: "object", properties: { b: {}, a: {} } };
46
+ t2.inputSchema = { properties: { a: {}, b: {} }, type: "object" };
47
+ const s1 = serializeManifest(buildManifest([t1], { version: 1, source: "s" }));
48
+ const s2 = serializeManifest(buildManifest([t2], { version: 1, source: "s" }));
49
+ expect(s1).toBe(s2);
50
+ });
51
+ });
@@ -0,0 +1,47 @@
1
+ import type { GeneratedTool, ToolManifest } from "../types";
2
+
3
+ /**
4
+ * The manifest is the committed source-of-truth artifact the CI drift gate
5
+ * (`mcp:check` → `12-apps/ci` `mcp-contract.yml`) diffs against a fresh
6
+ * regeneration. If an endpoint's schema changes without the manifest being
7
+ * regenerated, the diff fails the build — that is how the served MCP surface is
8
+ * kept in lockstep with the endpoint surface.
9
+ */
10
+
11
+ export interface BuildManifestOptions {
12
+ /** Bumped intentionally on any tool-shape change (mirrors the golden catalog). */
13
+ version: number;
14
+ /** Human label for the spec, e.g. "future-pay web @ openapi.json". */
15
+ source: string;
16
+ }
17
+
18
+ /** Sort object keys recursively so serialization is stable regardless of insertion order. */
19
+ function sortDeep(value: unknown): unknown {
20
+ if (Array.isArray(value)) return value.map(sortDeep);
21
+ if (value && typeof value === "object") {
22
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
23
+ a < b ? -1 : a > b ? 1 : 0,
24
+ );
25
+ return Object.fromEntries(entries.map(([key, val]) => [key, sortDeep(val)]));
26
+ }
27
+ return value;
28
+ }
29
+
30
+ export function buildManifest(
31
+ tools: GeneratedTool[],
32
+ options: BuildManifestOptions,
33
+ ): ToolManifest {
34
+ // Tools are sorted by name so the manifest ordering is deterministic across
35
+ // spec edits that reorder paths.
36
+ const sorted = [...tools].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
37
+ return { version: options.version, source: options.source, tools: sorted };
38
+ }
39
+
40
+ /**
41
+ * Canonical JSON for a manifest — deep-key-sorted and trailing-newline'd, so the
42
+ * committed artifact and a regeneration diff cleanly (no key-order or whitespace
43
+ * churn). `mcp:check` regenerates, serializes with this, and `git diff --exit-code`s.
44
+ */
45
+ export function serializeManifest(manifest: ToolManifest): string {
46
+ return `${JSON.stringify(sortDeep(manifest), null, 2)}\n`;
47
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { createToolRegistry } from "./registry";
4
+ import type { GeneratedTool, RequestAuth } from "../types";
5
+
6
+ const readTool: GeneratedTool = {
7
+ name: "getThing",
8
+ description: "Get a thing",
9
+ method: "GET",
10
+ path: "/things/{id}",
11
+ inputSchema: { type: "object", properties: { id: {} }, required: ["id"] },
12
+ parameters: [{ name: "id", in: "path", required: true, schema: {} }],
13
+ bodyProps: [],
14
+ bodyIsWhole: false,
15
+ mutating: false,
16
+ security: [],
17
+ };
18
+
19
+ const writeTool: GeneratedTool = { ...readTool, name: "makeThing", method: "POST", mutating: true };
20
+
21
+ const auth: RequestAuth = { bearer: "tok" };
22
+
23
+ function okFetch(): typeof fetch {
24
+ return (async () =>
25
+ new Response(JSON.stringify({ id: "1" }), {
26
+ status: 200,
27
+ headers: { "content-type": "application/json" },
28
+ })) as unknown as typeof fetch;
29
+ }
30
+
31
+ function forbiddenFetch(): typeof fetch {
32
+ return (async () =>
33
+ new Response(JSON.stringify({ error: "forbidden" }), {
34
+ status: 403,
35
+ headers: { "content-type": "application/json" },
36
+ })) as unknown as typeof fetch;
37
+ }
38
+
39
+ describe("createToolRegistry", () => {
40
+ it("lists tools as MCP descriptors and honours the visibility filter", () => {
41
+ const registry = createToolRegistry({
42
+ tools: [readTool, writeTool],
43
+ baseUrl: "https://app.example.com",
44
+ isVisible: (tool) => !tool.mutating,
45
+ });
46
+ const names = registry.listTools().map((t) => t.name);
47
+ expect(names).toEqual(["getThing"]);
48
+ expect(registry.listTools()[0].inputSchema).toEqual(readTool.inputSchema);
49
+ });
50
+
51
+ it("returns an error result for an unknown tool", async () => {
52
+ const registry = createToolRegistry({ tools: [readTool], baseUrl: "https://app.example.com" });
53
+ const result = await registry.callTool("nope", {}, auth);
54
+ expect(result.isError).toBe(true);
55
+ expect(result.content[0].text).toContain("Unknown tool");
56
+ });
57
+
58
+ it("proxies a successful call and returns a text result", async () => {
59
+ const registry = createToolRegistry({
60
+ tools: [readTool],
61
+ baseUrl: "https://app.example.com",
62
+ fetchImpl: okFetch(),
63
+ });
64
+ const result = await registry.callTool("getThing", { id: "1" }, auth);
65
+ expect(result.isError).toBe(false);
66
+ expect(JSON.parse(result.content[0].text)).toEqual({ id: "1" });
67
+ });
68
+
69
+ it("marks an upstream 403 as an error result (authz decided upstream)", async () => {
70
+ const registry = createToolRegistry({
71
+ tools: [readTool],
72
+ baseUrl: "https://app.example.com",
73
+ fetchImpl: forbiddenFetch(),
74
+ });
75
+ const result = await registry.callTool("getThing", { id: "1" }, auth);
76
+ expect(result.isError).toBe(true);
77
+ expect(result.content[0].text).toContain("forbidden");
78
+ });
79
+ });