@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 +13 -0
- package/README.md +25 -0
- package/build/esm/compose.mjs +375 -0
- package/build/esm/index.mjs +1 -0
- package/build/esm/mcp.mjs +69 -0
- package/build/esnext/compose.esnext +298 -0
- package/build/esnext/index.esnext +1 -0
- package/build/esnext/mcp.esnext +51 -0
- package/package.json +54 -0
- package/rollup.config.js +3 -0
- package/source/compose.test.ts +885 -0
- package/source/compose.ts +481 -0
- package/source/index.ts +2 -0
- package/source/mcp.ts +85 -0
- package/source/types.ts +69 -0
- package/tsconfig.json +4 -0
- package/vite.config.js +6 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
class UcpSchemaComposer {
|
|
2
|
+
/**
|
|
3
|
+
* Create a composed schema from a UCP profile. This function will look
|
|
4
|
+
* at the capabilities and payment handlers in the profile, fetch the
|
|
5
|
+
* JSON schemas (and any JSON schemas referenced within), and return an
|
|
6
|
+
* object that allows you to get a composed schema for a specific file
|
|
7
|
+
* and UCP operation.
|
|
8
|
+
*/
|
|
9
|
+
static async fromProfile({ ucp: { capabilities } }, {
|
|
10
|
+
fetch: fetchSchema = createDefaultSchemaFetcher()
|
|
11
|
+
} = {}) {
|
|
12
|
+
const resolvedCapabilities = await Promise.all(
|
|
13
|
+
capabilities.map(async (capability) => {
|
|
14
|
+
let json;
|
|
15
|
+
const schemaUrl = new URL(capability.schema);
|
|
16
|
+
const reverseDnsForUrl = schemaUrl.hostname.split(".").reverse().join(".");
|
|
17
|
+
if (!capability.name.startsWith(reverseDnsForUrl)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Invalid schema name: ${capability.name} does not match URL ${capability.schema}`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
json = await fetchSchema(capability.schema);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
throw new Error(`Schema not found for URL: ${capability.schema}`);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
capability,
|
|
29
|
+
schema: json
|
|
30
|
+
};
|
|
31
|
+
})
|
|
32
|
+
);
|
|
33
|
+
return new UcpSchemaComposer({
|
|
34
|
+
capabilities: resolvedCapabilities
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
#profile;
|
|
38
|
+
#profileNameToUrlMap = /* @__PURE__ */ new Map();
|
|
39
|
+
#profileUrlToCapabilityMap = /* @__PURE__ */ new Map();
|
|
40
|
+
#schemaMapByOperation = /* @__PURE__ */ new Map();
|
|
41
|
+
constructor(profile) {
|
|
42
|
+
this.#profile = profile;
|
|
43
|
+
for (const { capability } of profile.capabilities) {
|
|
44
|
+
this.#profileNameToUrlMap.set(capability.name, capability.schema);
|
|
45
|
+
this.#profileUrlToCapabilityMap.set(capability.schema, capability);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Get a schema file composer for the specified schema URL.
|
|
50
|
+
*
|
|
51
|
+
* @param schema - The schema URL or capability name
|
|
52
|
+
* @returns A UcpSchemaComposerFile instance, or undefined if not found
|
|
53
|
+
*/
|
|
54
|
+
get(schema) {
|
|
55
|
+
const url = this.#profileNameToUrlMap.get(schema) ?? schema;
|
|
56
|
+
const capability = this.#profileUrlToCapabilityMap.get(url);
|
|
57
|
+
if (capability == null) return void 0;
|
|
58
|
+
return new UcpSchemaComposerFile(url, { capability, composer: this });
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get all schema entries for a specific operation.
|
|
62
|
+
*
|
|
63
|
+
* @param options - Options for getting entries
|
|
64
|
+
* @param options.operation - The operation context
|
|
65
|
+
* @returns An iterator of schema URL and composed schema pairs
|
|
66
|
+
*/
|
|
67
|
+
entries({ operation = "read" } = {}) {
|
|
68
|
+
return this.#schemaMapForOperation(operation).entries();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Internal method to get the composed schema directly.
|
|
72
|
+
* Used by UcpSchemaComposerFile.
|
|
73
|
+
*/
|
|
74
|
+
composedSchema(schema, { operation = "read" } = {}) {
|
|
75
|
+
return this.#schemaMapForOperation(operation).get(schema);
|
|
76
|
+
}
|
|
77
|
+
#schemaMapForOperation(operation) {
|
|
78
|
+
let schemaMap = this.#schemaMapByOperation.get(operation);
|
|
79
|
+
if (schemaMap == null) {
|
|
80
|
+
schemaMap = createSchemaMap(this.#profile, { operation });
|
|
81
|
+
}
|
|
82
|
+
return schemaMap;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
class UcpSchemaComposerFile {
|
|
86
|
+
#url;
|
|
87
|
+
#composer;
|
|
88
|
+
capability;
|
|
89
|
+
constructor(url, {
|
|
90
|
+
composer,
|
|
91
|
+
capability
|
|
92
|
+
}) {
|
|
93
|
+
this.#url = url;
|
|
94
|
+
this.#composer = composer;
|
|
95
|
+
this.capability = capability;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Get the composed schema for a specific operation.
|
|
99
|
+
*/
|
|
100
|
+
composedSchema(options) {
|
|
101
|
+
const schema = this.#composer.composedSchema(this.#url, options);
|
|
102
|
+
if (schema == null) {
|
|
103
|
+
throw new ReferenceError(`Schema not found for URL: ${this.#url}`);
|
|
104
|
+
}
|
|
105
|
+
return schema;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function createSchemaMap({ capabilities }, { operation }) {
|
|
109
|
+
const schemaMap = /* @__PURE__ */ new Map();
|
|
110
|
+
const schemaNameToUrlMap = /* @__PURE__ */ new Map();
|
|
111
|
+
for (const { capability, schema } of capabilities) {
|
|
112
|
+
schemaNameToUrlMap.set(capability.name, capability.schema);
|
|
113
|
+
const clonedSchema = structuredClone(schema);
|
|
114
|
+
processSchemaUcpMetadata(clonedSchema, operation);
|
|
115
|
+
schemaMap.set(capability.schema, clonedSchema);
|
|
116
|
+
if (capability.extends == null) continue;
|
|
117
|
+
const defs = clonedSchema.$defs;
|
|
118
|
+
const extendedSchemas = Array.isArray(capability.extends) ? capability.extends : [capability.extends];
|
|
119
|
+
for (const extendedSchemaName of extendedSchemas) {
|
|
120
|
+
const extensionDef = defs?.[extendedSchemaName];
|
|
121
|
+
if (extensionDef?.allOf == null) continue;
|
|
122
|
+
const extendedSchemaUrl = schemaNameToUrlMap.get(extendedSchemaName);
|
|
123
|
+
const extendedSchema = extendedSchemaUrl ? schemaMap.get(extendedSchemaUrl) : void 0;
|
|
124
|
+
if (extendedSchema == null) continue;
|
|
125
|
+
if (extendedSchema.allOf?.[0]?.$ref !== `#/$defs/${extendedSchemaName}`) {
|
|
126
|
+
const newDef = {
|
|
127
|
+
type: "object",
|
|
128
|
+
title: `${extendedSchema.title ?? extendedSchemaName} (base)`,
|
|
129
|
+
...extendedSchema.properties ? { properties: extendedSchema.properties } : {},
|
|
130
|
+
...extendedSchema.required ? { required: extendedSchema.required } : {},
|
|
131
|
+
...extendedSchema.items ? { items: extendedSchema.items } : {},
|
|
132
|
+
...extendedSchema.allOf ? { allOf: extendedSchema.allOf } : {},
|
|
133
|
+
...extendedSchema.oneOf ? { oneOf: extendedSchema.oneOf } : {}
|
|
134
|
+
};
|
|
135
|
+
extendedSchema.$defs ??= {};
|
|
136
|
+
extendedSchema.$defs[extendedSchemaName] = newDef;
|
|
137
|
+
extendedSchema.allOf = [
|
|
138
|
+
{ type: "object", $ref: `#/$defs/${extendedSchemaName}` }
|
|
139
|
+
];
|
|
140
|
+
delete extendedSchema.properties;
|
|
141
|
+
delete extendedSchema.required;
|
|
142
|
+
delete extendedSchema.items;
|
|
143
|
+
delete extendedSchema.oneOf;
|
|
144
|
+
}
|
|
145
|
+
const clonedDefs = structuredClone(defs);
|
|
146
|
+
const filtereExtensionDefAllOf = extensionDef.allOf.filter(
|
|
147
|
+
({ $ref }) => $ref == null || new URL($ref, capability.schema).href !== extendedSchemaUrl
|
|
148
|
+
);
|
|
149
|
+
if (filtereExtensionDefAllOf.length > 1) {
|
|
150
|
+
clonedDefs[extendedSchemaName] = {
|
|
151
|
+
type: "object",
|
|
152
|
+
allOf: filtereExtensionDefAllOf
|
|
153
|
+
};
|
|
154
|
+
} else {
|
|
155
|
+
const { allOf, ...rest } = extensionDef;
|
|
156
|
+
clonedDefs[extendedSchemaName] = {
|
|
157
|
+
type: "object",
|
|
158
|
+
...rest,
|
|
159
|
+
...filtereExtensionDefAllOf[0]
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
for (const otherExtendedSchemaName of extendedSchemas) {
|
|
163
|
+
if (otherExtendedSchemaName === extendedSchemaName) continue;
|
|
164
|
+
delete clonedDefs[otherExtendedSchemaName];
|
|
165
|
+
}
|
|
166
|
+
Object.assign(
|
|
167
|
+
extendedSchema.$defs,
|
|
168
|
+
namespaceExtensionDefs(clonedDefs, capability)
|
|
169
|
+
);
|
|
170
|
+
extendedSchema.allOf.push({
|
|
171
|
+
type: "object",
|
|
172
|
+
$ref: `#/$defs/${namespaceIdentifier(extendedSchemaName, capability)}`
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
get(schema) {
|
|
178
|
+
if (schemaNameToUrlMap.has(schema)) {
|
|
179
|
+
return schemaMap.get(schemaNameToUrlMap.get(schema));
|
|
180
|
+
}
|
|
181
|
+
return schemaMap.get(schema);
|
|
182
|
+
},
|
|
183
|
+
entries() {
|
|
184
|
+
return schemaMap.entries();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function processSchemaUcpMetadata(schema, operation) {
|
|
189
|
+
let requiredNeedsUpdate = false;
|
|
190
|
+
const updatedRequired = new Set(schema.required);
|
|
191
|
+
if (schema.properties != null) {
|
|
192
|
+
for (const [key, value] of Object.entries(schema.properties)) {
|
|
193
|
+
processSchemaUcpMetadata(value, operation);
|
|
194
|
+
if (value.ucp_request == null) continue;
|
|
195
|
+
const ucpRequest = typeof value.ucp_request === "string" ? value.ucp_request : value.ucp_request[operation];
|
|
196
|
+
delete value.ucp_request;
|
|
197
|
+
if (operation === "read") continue;
|
|
198
|
+
switch (ucpRequest) {
|
|
199
|
+
case "omit":
|
|
200
|
+
delete schema.properties[key];
|
|
201
|
+
if (updatedRequired.has(key)) {
|
|
202
|
+
requiredNeedsUpdate = true;
|
|
203
|
+
updatedRequired.delete(key);
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
206
|
+
case "required":
|
|
207
|
+
if (!updatedRequired.has(key)) {
|
|
208
|
+
requiredNeedsUpdate = true;
|
|
209
|
+
updatedRequired.add(key);
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
case "optional":
|
|
213
|
+
if (updatedRequired.has(key)) {
|
|
214
|
+
requiredNeedsUpdate = true;
|
|
215
|
+
updatedRequired.delete(key);
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (requiredNeedsUpdate) {
|
|
222
|
+
schema.required = Array.from(updatedRequired);
|
|
223
|
+
}
|
|
224
|
+
if (schema.items != null) {
|
|
225
|
+
if (Array.isArray(schema.items)) {
|
|
226
|
+
for (const item of schema.items) {
|
|
227
|
+
processSchemaUcpMetadata(item, operation);
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
processSchemaUcpMetadata(schema.items, operation);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (schema.allOf != null) {
|
|
234
|
+
for (const allOfSchema of schema.allOf) {
|
|
235
|
+
processSchemaUcpMetadata(allOfSchema, operation);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (schema.oneOf != null) {
|
|
239
|
+
for (const oneOfSchema of schema.oneOf) {
|
|
240
|
+
processSchemaUcpMetadata(oneOfSchema, operation);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (schema.$defs != null) {
|
|
244
|
+
for (const value of Object.values(schema.$defs)) {
|
|
245
|
+
processSchemaUcpMetadata(value, operation);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return schema;
|
|
249
|
+
}
|
|
250
|
+
function namespaceIdentifier(identifier, capability) {
|
|
251
|
+
return `${capability.name}~${identifier}`;
|
|
252
|
+
}
|
|
253
|
+
function namespaceExtensionDefs(defs, capability) {
|
|
254
|
+
const newDefs = {};
|
|
255
|
+
for (const [key, value] of Object.entries(defs)) {
|
|
256
|
+
newDefs[namespaceIdentifier(key, capability)] = updateRefsWithNamespace(
|
|
257
|
+
value,
|
|
258
|
+
capability
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return newDefs;
|
|
262
|
+
}
|
|
263
|
+
function updateRefsWithNamespace(obj, capability) {
|
|
264
|
+
if (obj === null || typeof obj !== "object") {
|
|
265
|
+
return obj;
|
|
266
|
+
}
|
|
267
|
+
if (Array.isArray(obj)) {
|
|
268
|
+
return obj.map((item) => updateRefsWithNamespace(item, capability));
|
|
269
|
+
}
|
|
270
|
+
const cloned = obj;
|
|
271
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
272
|
+
if (key === "$ref" && typeof value === "string") {
|
|
273
|
+
const match = value.match(/^#\/\$defs\/(.+)$/);
|
|
274
|
+
if (match?.[1]) {
|
|
275
|
+
cloned[key] = `#/$defs/${namespaceIdentifier(match[1], capability)}`;
|
|
276
|
+
} else {
|
|
277
|
+
cloned[key] = value;
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
cloned[key] = updateRefsWithNamespace(value, capability);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return cloned;
|
|
284
|
+
}
|
|
285
|
+
function createDefaultSchemaFetcher() {
|
|
286
|
+
const cache = /* @__PURE__ */ new Map();
|
|
287
|
+
return (url) => {
|
|
288
|
+
const cached = cache.get(url);
|
|
289
|
+
if (cached) {
|
|
290
|
+
return cached;
|
|
291
|
+
}
|
|
292
|
+
const promise = fetch(url).then((response) => response.json());
|
|
293
|
+
cache.set(url, promise);
|
|
294
|
+
return promise;
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export { UcpSchemaComposer, UcpSchemaComposerFile };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UcpSchemaComposer } from './compose.esnext';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
function jsonSchemaToOutputSchema(baseSchema) {
|
|
4
|
+
const { properties, required } = flattenJsonSchema(baseSchema);
|
|
5
|
+
const requiredProperties = new Set(required);
|
|
6
|
+
const outputSchema = {};
|
|
7
|
+
for (const [propertyName, propertySchema] of Object.entries(properties)) {
|
|
8
|
+
const zodType = z.fromJSONSchema(
|
|
9
|
+
{
|
|
10
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
11
|
+
...propertySchema,
|
|
12
|
+
$defs: baseSchema.$defs
|
|
13
|
+
},
|
|
14
|
+
{ defaultTarget: "openapi-3.0" }
|
|
15
|
+
);
|
|
16
|
+
outputSchema[propertyName] = requiredProperties.has(propertyName) ? zodType : zodType.optional();
|
|
17
|
+
}
|
|
18
|
+
return outputSchema;
|
|
19
|
+
}
|
|
20
|
+
function flattenJsonSchema(baseSchema) {
|
|
21
|
+
const properties = {};
|
|
22
|
+
const required = /* @__PURE__ */ new Set();
|
|
23
|
+
const mergedSchemas = [
|
|
24
|
+
baseSchema,
|
|
25
|
+
...(baseSchema.allOf ?? []).flatMap((schema) => {
|
|
26
|
+
if (schema.properties) {
|
|
27
|
+
return schema;
|
|
28
|
+
}
|
|
29
|
+
if (schema.$ref?.startsWith("#/$defs/")) {
|
|
30
|
+
return baseSchema.$defs?.[schema.$ref.slice("#/$defs/".length)] ?? [];
|
|
31
|
+
}
|
|
32
|
+
return [];
|
|
33
|
+
})
|
|
34
|
+
];
|
|
35
|
+
for (const schema of mergedSchemas) {
|
|
36
|
+
if (schema.properties) {
|
|
37
|
+
Object.assign(properties, schema.properties);
|
|
38
|
+
}
|
|
39
|
+
if (schema.required) {
|
|
40
|
+
for (const requiredPropertyName of schema.required) {
|
|
41
|
+
required.add(requiredPropertyName);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
properties,
|
|
47
|
+
required: Array.from(required)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { jsonSchemaToOutputSchema };
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lemonmade/ucp-schema",
|
|
3
|
+
"description": "Tools for composing Universal Commerce Protocol (UCP) schemas",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public",
|
|
8
|
+
"@lemonmade/registry": "https://registry.npmjs.org"
|
|
9
|
+
},
|
|
10
|
+
"version": "0.0.0-preview-20260123174501",
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=18.0.0"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/<USER>/<REPOSITORY>",
|
|
17
|
+
"directory": "packages/ucp-schema"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./build/typescript/index.d.ts",
|
|
22
|
+
"quilt:source": "./source/index.ts",
|
|
23
|
+
"quilt:esnext": "./build/esnext/index.esnext",
|
|
24
|
+
"import": "./build/esm/index.mjs"
|
|
25
|
+
},
|
|
26
|
+
"./mcp": {
|
|
27
|
+
"types": "./build/typescript/mcp.d.ts",
|
|
28
|
+
"quilt:source": "./source/mcp.ts",
|
|
29
|
+
"quilt:esnext": "./build/esnext/mcp.esnext",
|
|
30
|
+
"import": "./build/esm/mcp.mjs"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"types": "./build/typescript/index.d.ts",
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"dependencies": {},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"zod": ">=4.0.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"zod": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"zod": "^4.3.0"
|
|
46
|
+
},
|
|
47
|
+
"browserslist": [
|
|
48
|
+
"defaults and fully supports es6-module",
|
|
49
|
+
"maintained node versions"
|
|
50
|
+
],
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "rollup --config ./rollup.config.js"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/rollup.config.js
ADDED