@lemonmade/ucp-schema 0.0.0-preview-20260123174501

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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # @lemonmade/ucp-schema
2
+
3
+ ## 0.0.0-preview-20260123174501
4
+
5
+ ### Patch Changes
6
+
7
+ - Add MCP entrypoint
8
+
9
+ ## 0.1.1
10
+
11
+ ### Patch Changes
12
+
13
+ - [`78291cc`](https://github.com/lemonmade/nursery/commit/78291cc317b2c3499ab09b543dea1aede2fd9495) Thanks [@lemonmade](https://github.com/lemonmade)! - Add MCP entrypoint
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # `@lemonmade/ucp-schema`
2
+
3
+ Tools for composing Universal Commerce Protocol (UCP) schemas.
4
+
5
+ ## Basic usage
6
+
7
+ ```ts
8
+ import {UcpSchemaComposer} from '@lemonmade/ucp-schema';
9
+
10
+ const composer = await UcpSchemaComposer.fromProfile({
11
+ ucp: {
12
+ capabilities: [
13
+ // ... more capabilities ...
14
+ ],
15
+ },
16
+ });
17
+
18
+ const checkoutFile = composer.get(
19
+ 'https://ucp.dev/schemas/shopping/checkout.json',
20
+ );
21
+ const checkoutReadSchema = checkoutFile.composedSchema();
22
+ const checkoutCreateSchema = checkoutFile.composedSchema({operation: 'create'});
23
+
24
+ // ... use the JSON schemas to validate and transform UCP requests
25
+ ```
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Composes operation-aware JSON schemas for UCP objects, based on the capabilities
3
+ * referenced in a UCP profile.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * const composer = await UcpSchemaComposer.fromProfile({
8
+ * ucp: {
9
+ * capabilities: [
10
+ * {
11
+ * name: 'dev.ucp.shopping.checkout',
12
+ * version: '2026-01-11',
13
+ * spec: 'https://ucp.dev/specification/checkout',
14
+ * schema: 'https://ucp.dev/schemas/shopping/checkout.json',
15
+ * },
16
+ * // ... more capabilities ...
17
+ * ],
18
+ * },
19
+ * });
20
+ *
21
+ * const checkoutFile = composer.get('https://ucp.dev/schemas/shopping/checkout.json');
22
+ * const checkoutReadSchema = checkoutFile.composedSchema();
23
+ * const checkoutCreateSchema = checkoutFile.composedSchema({ operation: 'create' });
24
+ * ```
25
+ */
26
+ class UcpSchemaComposer {
27
+ /**
28
+ * Create a composed schema from a UCP profile. This function will look
29
+ * at the capabilities and payment handlers in the profile, fetch the
30
+ * JSON schemas (and any JSON schemas referenced within), and return an
31
+ * object that allows you to get a composed schema for a specific file
32
+ * and UCP operation.
33
+ */
34
+ static async fromProfile({
35
+ ucp: {
36
+ capabilities
37
+ }
38
+ }, {
39
+ fetch: fetchSchema = createDefaultSchemaFetcher()
40
+ } = {}) {
41
+ const resolvedCapabilities = await Promise.all(capabilities.map(async capability => {
42
+ let json;
43
+ const schemaUrl = new URL(capability.schema);
44
+ const reverseDnsForUrl = schemaUrl.hostname.split('.').reverse().join('.');
45
+ if (!capability.name.startsWith(reverseDnsForUrl)) {
46
+ throw new Error(`Invalid schema name: ${capability.name} does not match URL ${capability.schema}`);
47
+ }
48
+ try {
49
+ // TODO: also need to fetch more JSON schemas that may be referenced by the fields in this one
50
+ json = await fetchSchema(capability.schema);
51
+ } catch (error) {
52
+ throw new Error(`Schema not found for URL: ${capability.schema}`);
53
+ }
54
+ return {
55
+ capability,
56
+ schema: json
57
+ };
58
+ }));
59
+ return new UcpSchemaComposer({
60
+ capabilities: resolvedCapabilities
61
+ });
62
+ }
63
+ #profile;
64
+ #profileNameToUrlMap = new Map();
65
+ #profileUrlToCapabilityMap = new Map();
66
+ #schemaMapByOperation = new Map();
67
+ constructor(profile) {
68
+ this.#profile = profile;
69
+ for (const {
70
+ capability
71
+ } of profile.capabilities) {
72
+ this.#profileNameToUrlMap.set(capability.name, capability.schema);
73
+ this.#profileUrlToCapabilityMap.set(capability.schema, capability);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Get a schema file composer for the specified schema URL.
79
+ *
80
+ * @param schema - The schema URL or capability name
81
+ * @returns A UcpSchemaComposerFile instance, or undefined if not found
82
+ */
83
+ get(schema) {
84
+ const url = this.#profileNameToUrlMap.get(schema) ?? schema;
85
+ const capability = this.#profileUrlToCapabilityMap.get(url);
86
+
87
+ // TODO: we should allow any schema URL, not just the ones that were directly referenced
88
+ // in the profile. Right now, we do not traverse the graph of other JSON schemas that may be referenced
89
+ // by the fields in the profile capabilities.
90
+ if (capability == null) return undefined;
91
+ return new UcpSchemaComposerFile(url, {
92
+ capability,
93
+ composer: this
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Get all schema entries for a specific operation.
99
+ *
100
+ * @param options - Options for getting entries
101
+ * @param options.operation - The operation context
102
+ * @returns An iterator of schema URL and composed schema pairs
103
+ */
104
+ entries({
105
+ operation = 'read'
106
+ } = {}) {
107
+ return this.#schemaMapForOperation(operation).entries();
108
+ }
109
+
110
+ /**
111
+ * Internal method to get the composed schema directly.
112
+ * Used by UcpSchemaComposerFile.
113
+ */
114
+ composedSchema(schema, {
115
+ operation = 'read'
116
+ } = {}) {
117
+ return this.#schemaMapForOperation(operation).get(schema);
118
+ }
119
+ #schemaMapForOperation(operation) {
120
+ let schemaMap = this.#schemaMapByOperation.get(operation);
121
+ if (schemaMap == null) {
122
+ schemaMap = createSchemaMap(this.#profile, {
123
+ operation
124
+ });
125
+ }
126
+ return schemaMap;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Represents a schema file that can be composed with different operation contexts.
132
+ */
133
+ class UcpSchemaComposerFile {
134
+ #url;
135
+ #composer;
136
+ constructor(url, {
137
+ composer,
138
+ capability
139
+ }) {
140
+ this.#url = url;
141
+ this.#composer = composer;
142
+ this.capability = capability;
143
+ }
144
+
145
+ /**
146
+ * Get the composed schema for a specific operation.
147
+ */
148
+ composedSchema(options) {
149
+ const schema = this.#composer.composedSchema(this.#url, options);
150
+ if (schema == null) {
151
+ throw new ReferenceError(`Schema not found for URL: ${this.#url}`);
152
+ }
153
+ return schema;
154
+ }
155
+ }
156
+
157
+ // Helpers
158
+
159
+ /**
160
+ * Compose schemas by resolving extensions
161
+ */
162
+ function createSchemaMap({
163
+ capabilities
164
+ }, {
165
+ operation
166
+ }) {
167
+ const schemaMap = new Map();
168
+ const schemaNameToUrlMap = new Map();
169
+ for (const {
170
+ capability,
171
+ schema
172
+ } of capabilities) {
173
+ schemaNameToUrlMap.set(capability.name, capability.schema);
174
+ const clonedSchema = structuredClone(schema);
175
+ processSchemaUcpMetadata(clonedSchema, operation);
176
+ schemaMap.set(capability.schema, clonedSchema);
177
+ if (capability.extends == null) continue;
178
+ const defs = clonedSchema.$defs;
179
+ const extendedSchemas = Array.isArray(capability.extends) ? capability.extends : [capability.extends];
180
+ for (const extendedSchemaName of extendedSchemas) {
181
+ const extensionDef = defs?.[extendedSchemaName];
182
+ if (extensionDef?.allOf == null) continue;
183
+ const extendedSchemaUrl = schemaNameToUrlMap.get(extendedSchemaName);
184
+ const extendedSchema = extendedSchemaUrl ? schemaMap.get(extendedSchemaUrl) : undefined;
185
+ if (extendedSchema == null) continue;
186
+
187
+ // Make the base type ready for extension
188
+ if (extendedSchema.allOf?.[0]?.$ref !== `#/$defs/${extendedSchemaName}`) {
189
+ const newDef = {
190
+ type: 'object',
191
+ title: `${extendedSchema.title ?? extendedSchemaName} (base)`,
192
+ ...(extendedSchema.properties ? {
193
+ properties: extendedSchema.properties
194
+ } : {}),
195
+ ...(extendedSchema.required ? {
196
+ required: extendedSchema.required
197
+ } : {}),
198
+ ...(extendedSchema.items ? {
199
+ items: extendedSchema.items
200
+ } : {}),
201
+ ...(extendedSchema.allOf ? {
202
+ allOf: extendedSchema.allOf
203
+ } : {}),
204
+ ...(extendedSchema.oneOf ? {
205
+ oneOf: extendedSchema.oneOf
206
+ } : {})
207
+ };
208
+ extendedSchema.$defs ??= {};
209
+ extendedSchema.$defs[extendedSchemaName] = newDef;
210
+ extendedSchema.allOf = [{
211
+ type: 'object',
212
+ $ref: `#/$defs/${extendedSchemaName}`
213
+ }];
214
+ delete extendedSchema.properties;
215
+ delete extendedSchema.required;
216
+ delete extendedSchema.items;
217
+ delete extendedSchema.oneOf;
218
+ }
219
+ const clonedDefs = structuredClone(defs);
220
+
221
+ // Rewrite the extension def to remove a reference to the base type, we will manually
222
+ // create an `allOf` type for it that includes all schema additions.
223
+ const filtereExtensionDefAllOf = extensionDef.allOf.filter(({
224
+ $ref
225
+ }) => $ref == null || new URL($ref, capability.schema).href !== extendedSchemaUrl);
226
+ if (filtereExtensionDefAllOf.length > 1) {
227
+ clonedDefs[extendedSchemaName] = {
228
+ type: 'object',
229
+ allOf: filtereExtensionDefAllOf
230
+ };
231
+ } else {
232
+ const {
233
+ allOf,
234
+ ...rest
235
+ } = extensionDef;
236
+ clonedDefs[extendedSchemaName] = {
237
+ type: 'object',
238
+ ...rest,
239
+ ...filtereExtensionDefAllOf[0]
240
+ };
241
+ }
242
+
243
+ // TODO: turn this into a smarter def pruning algorithm
244
+ for (const otherExtendedSchemaName of extendedSchemas) {
245
+ if (otherExtendedSchemaName === extendedSchemaName) continue;
246
+ delete clonedDefs[otherExtendedSchemaName];
247
+ }
248
+ Object.assign(extendedSchema.$defs, namespaceExtensionDefs(clonedDefs, capability));
249
+ extendedSchema.allOf.push({
250
+ type: 'object',
251
+ $ref: `#/$defs/${namespaceIdentifier(extendedSchemaName, capability)}`
252
+ });
253
+ }
254
+ }
255
+ return {
256
+ get(schema) {
257
+ if (schemaNameToUrlMap.has(schema)) {
258
+ return schemaMap.get(schemaNameToUrlMap.get(schema));
259
+ }
260
+ return schemaMap.get(schema);
261
+ },
262
+ entries() {
263
+ return schemaMap.entries();
264
+ }
265
+ };
266
+ }
267
+ function processSchemaUcpMetadata(schema, operation) {
268
+ let requiredNeedsUpdate = false;
269
+ const updatedRequired = new Set(schema.required);
270
+ if (schema.properties != null) {
271
+ for (const [key, value] of Object.entries(schema.properties)) {
272
+ processSchemaUcpMetadata(value, operation);
273
+ if (value.ucp_request == null) continue;
274
+ const ucpRequest = typeof value.ucp_request === 'string' ? value.ucp_request : value.ucp_request[operation];
275
+ delete value.ucp_request;
276
+ if (operation === 'read') continue;
277
+ switch (ucpRequest) {
278
+ case 'omit':
279
+ delete schema.properties[key];
280
+ if (updatedRequired.has(key)) {
281
+ requiredNeedsUpdate = true;
282
+ updatedRequired.delete(key);
283
+ }
284
+ break;
285
+ case 'required':
286
+ if (!updatedRequired.has(key)) {
287
+ requiredNeedsUpdate = true;
288
+ updatedRequired.add(key);
289
+ }
290
+ break;
291
+ case 'optional':
292
+ if (updatedRequired.has(key)) {
293
+ requiredNeedsUpdate = true;
294
+ updatedRequired.delete(key);
295
+ }
296
+ break;
297
+ }
298
+ }
299
+ }
300
+ if (requiredNeedsUpdate) {
301
+ schema.required = Array.from(updatedRequired);
302
+ }
303
+ if (schema.items != null) {
304
+ if (Array.isArray(schema.items)) {
305
+ for (const item of schema.items) {
306
+ processSchemaUcpMetadata(item, operation);
307
+ }
308
+ } else {
309
+ processSchemaUcpMetadata(schema.items, operation);
310
+ }
311
+ }
312
+ if (schema.allOf != null) {
313
+ for (const allOfSchema of schema.allOf) {
314
+ processSchemaUcpMetadata(allOfSchema, operation);
315
+ }
316
+ }
317
+ if (schema.oneOf != null) {
318
+ for (const oneOfSchema of schema.oneOf) {
319
+ processSchemaUcpMetadata(oneOfSchema, operation);
320
+ }
321
+ }
322
+ if (schema.$defs != null) {
323
+ for (const value of Object.values(schema.$defs)) {
324
+ processSchemaUcpMetadata(value, operation);
325
+ }
326
+ }
327
+ return schema;
328
+ }
329
+ function namespaceIdentifier(identifier, capability) {
330
+ return `${capability.name}~${identifier}`;
331
+ }
332
+ function namespaceExtensionDefs(defs, capability) {
333
+ const newDefs = {};
334
+ for (const [key, value] of Object.entries(defs)) {
335
+ newDefs[namespaceIdentifier(key, capability)] = updateRefsWithNamespace(value, capability);
336
+ }
337
+ return newDefs;
338
+ }
339
+ function updateRefsWithNamespace(obj, capability) {
340
+ if (obj === null || typeof obj !== 'object') {
341
+ return obj;
342
+ }
343
+ if (Array.isArray(obj)) {
344
+ return obj.map(item => updateRefsWithNamespace(item, capability));
345
+ }
346
+ const cloned = obj;
347
+ for (const [key, value] of Object.entries(obj)) {
348
+ if (key === '$ref' && typeof value === 'string') {
349
+ // Update internal references like #/$defs/fulfillment_method
350
+ const match = value.match(/^#\/\$defs\/(.+)$/);
351
+ if (match?.[1]) {
352
+ cloned[key] = `#/$defs/${namespaceIdentifier(match[1], capability)}`;
353
+ } else {
354
+ cloned[key] = value;
355
+ }
356
+ } else {
357
+ cloned[key] = updateRefsWithNamespace(value, capability);
358
+ }
359
+ }
360
+ return cloned;
361
+ }
362
+ function createDefaultSchemaFetcher() {
363
+ const cache = new Map();
364
+ return url => {
365
+ const cached = cache.get(url);
366
+ if (cached) {
367
+ return cached;
368
+ }
369
+ const promise = fetch(url).then(response => response.json());
370
+ cache.set(url, promise);
371
+ return promise;
372
+ };
373
+ }
374
+
375
+ export { UcpSchemaComposer, UcpSchemaComposerFile };
@@ -0,0 +1 @@
1
+ export { UcpSchemaComposer } from './compose.mjs';
@@ -0,0 +1,69 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Converts a JSON schema object into a record, where each key is its own
5
+ * independent Zod type, matching all the resolved properties in the source
6
+ * JSON schema. This is needed for the `outputSchema` property of the MCP
7
+ * package, which expects a record of types, somewhat at odds with the
8
+ * MCP UCP specification which defines the result type of most operations as
9
+ * a whole, resolved `Checkout` object.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * const composer = await UcpSchemaComposer.fromProfile(profile);
14
+ * const checkout = composer.get('https://ucp.dev/schemas/shopping/checkout.json');
15
+ * const outputSchema = jsonSchemaToOutputSchema(checkout.composedSchema());
16
+ *
17
+ * mcpServer.registerTool('get_checkout', {outputSchema}, getCheckout);
18
+ * ```
19
+ */
20
+ function jsonSchemaToOutputSchema(baseSchema) {
21
+ const {
22
+ properties,
23
+ required
24
+ } = flattenJsonSchema(baseSchema);
25
+ const requiredProperties = new Set(required);
26
+ const outputSchema = {};
27
+ for (const [propertyName, propertySchema] of Object.entries(properties)) {
28
+ // We need to create a stub type that can fully resolve, so we need to include all the defs
29
+ // in the schema.
30
+ const zodType = z.fromJSONSchema({
31
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
32
+ ...propertySchema,
33
+ $defs: baseSchema.$defs
34
+ }, {
35
+ defaultTarget: 'openapi-3.0'
36
+ });
37
+ outputSchema[propertyName] = requiredProperties.has(propertyName) ? zodType : zodType.optional();
38
+ }
39
+ return outputSchema;
40
+ }
41
+ function flattenJsonSchema(baseSchema) {
42
+ const properties = {};
43
+ const required = new Set();
44
+ const mergedSchemas = [baseSchema, ...(baseSchema.allOf ?? []).flatMap(schema => {
45
+ if (schema.properties) {
46
+ return schema;
47
+ }
48
+ if (schema.$ref?.startsWith('#/$defs/')) {
49
+ return baseSchema.$defs?.[schema.$ref.slice('#/$defs/'.length)] ?? [];
50
+ }
51
+ return [];
52
+ })];
53
+ for (const schema of mergedSchemas) {
54
+ if (schema.properties) {
55
+ Object.assign(properties, schema.properties);
56
+ }
57
+ if (schema.required) {
58
+ for (const requiredPropertyName of schema.required) {
59
+ required.add(requiredPropertyName);
60
+ }
61
+ }
62
+ }
63
+ return {
64
+ properties,
65
+ required: Array.from(required)
66
+ };
67
+ }
68
+
69
+ export { jsonSchemaToOutputSchema };