@happyvertical/smrt-core 0.40.59 → 0.40.60

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.
@@ -1,8 +1,9 @@
1
- import { SMRT_CUSTOM_ACTION_ERROR_METADATA_KEY, buildCustomActionInputSchema, buildCustomActionInvocationArgs, customActionParameterInputName, normalizeCustomActionFailure, resolveCustomActionMetadata } from "./custom-action.js";
1
+ import { SMRT_CUSTOM_ACTION_ERROR_METADATA_KEY, buildCustomActionInvocationArgs, customActionParameterInputName, normalizeCustomActionFailure, resolveCustomActionMetadata } from "./custom-action.js";
2
2
  import { ObjectRegistry } from "../registry.js";
3
3
  import { SmrtCollection } from "../collection.js";
4
4
  import { runWithTenantGate } from "./tenant-gate.js";
5
5
  import { generateClaudeConfig, generateMCPDocumentation, generateMCPScript, generateRuntimeBootstrap } from "./mcp-runtime-template.js";
6
+ import { buildToolInputSchema, fieldTypeToJsonSchema, finalizeMcpJsonSchema } from "./tool-schema.js";
6
7
  import { dirname, resolve } from "node:path";
7
8
  import { mkdir, writeFile } from "node:fs/promises";
8
9
  //#region src/generators/mcp.ts
@@ -96,96 +97,32 @@ var MCPGenerator = class {
96
97
  if (shouldInclude("list")) tools.push({
97
98
  name: `${lowerName}_list`,
98
99
  description: `List ${objectName} objects with optional filtering`,
99
- inputSchema: {
100
- type: "object",
101
- properties: {
102
- limit: {
103
- type: "integer",
104
- description: "Maximum number of items to return",
105
- default: 50,
106
- minimum: 1,
107
- maximum: 1e3
108
- },
109
- offset: {
110
- type: "integer",
111
- description: "Number of items to skip",
112
- default: 0,
113
- minimum: 0
114
- },
115
- orderBy: {
116
- type: "string",
117
- description: "Field to order by (e.g., \"created_at DESC\")"
118
- },
119
- where: {
120
- type: "object",
121
- description: "Filter conditions as key-value pairs",
122
- additionalProperties: true
123
- }
124
- }
125
- }
100
+ inputSchema: this.buildInputSchema(objectName, "list", fields),
101
+ outputSchema: this.buildOutputSchema(objectName, "list", fields)
126
102
  });
127
103
  if (shouldInclude("get")) tools.push({
128
104
  name: `${lowerName}_get`,
129
105
  description: `Get a specific ${objectName} by ID or slug`,
130
- inputSchema: {
131
- type: "object",
132
- properties: {
133
- id: {
134
- type: "string",
135
- description: "Unique identifier of the object"
136
- },
137
- slug: {
138
- type: "string",
139
- description: "URL-friendly identifier of the object"
140
- }
141
- },
142
- required: ["id"]
143
- }
106
+ inputSchema: this.buildInputSchema(objectName, "get", fields),
107
+ outputSchema: this.buildOutputSchema(objectName, "get", fields)
108
+ });
109
+ if (shouldInclude("create")) tools.push({
110
+ name: `${lowerName}_create`,
111
+ description: `Create a new ${objectName}`,
112
+ inputSchema: this.buildInputSchema(objectName, "create", fields),
113
+ outputSchema: this.buildOutputSchema(objectName, "create", fields)
114
+ });
115
+ if (shouldInclude("update")) tools.push({
116
+ name: `${lowerName}_update`,
117
+ description: `Update an existing ${objectName}`,
118
+ inputSchema: this.buildInputSchema(objectName, "update", fields),
119
+ outputSchema: this.buildOutputSchema(objectName, "update", fields)
144
120
  });
145
- if (shouldInclude("create")) {
146
- const properties = {};
147
- const required = [];
148
- for (const [fieldName, field] of fields) {
149
- properties[fieldName] = this.fieldToMCPSchema(field);
150
- if (field._meta?.required) required.push(fieldName);
151
- }
152
- tools.push({
153
- name: `${lowerName}_create`,
154
- description: `Create a new ${objectName}`,
155
- inputSchema: {
156
- type: "object",
157
- properties,
158
- required
159
- }
160
- });
161
- }
162
- if (shouldInclude("update")) {
163
- const properties = { id: {
164
- type: "string",
165
- description: "ID of the object to update"
166
- } };
167
- for (const [fieldName, field] of fields) properties[fieldName] = this.fieldToMCPSchema(field);
168
- tools.push({
169
- name: `${lowerName}_update`,
170
- description: `Update an existing ${objectName}`,
171
- inputSchema: {
172
- type: "object",
173
- properties,
174
- required: ["id"]
175
- }
176
- });
177
- }
178
121
  if (shouldInclude("delete")) tools.push({
179
122
  name: `${lowerName}_delete`,
180
123
  description: `Delete a ${objectName} by ID`,
181
- inputSchema: {
182
- type: "object",
183
- properties: { id: {
184
- type: "string",
185
- description: "ID of the object to delete"
186
- } },
187
- required: ["id"]
188
- }
124
+ inputSchema: this.buildInputSchema(objectName, "delete", fields),
125
+ outputSchema: this.buildOutputSchema(objectName, "delete", fields)
189
126
  });
190
127
  if (classInfo) {
191
128
  const mcpConfig = ObjectRegistry.getConfig(objectName).mcp;
@@ -227,7 +164,8 @@ var MCPGenerator = class {
227
164
  return {
228
165
  name: `${lowerName}_${methodName}`.toLowerCase(),
229
166
  description: `Execute ${methodName} action on ${objectName}`,
230
- inputSchema: buildCustomActionInputSchema(metadata)
167
+ inputSchema: this.buildInputSchema(objectName, methodName, ObjectRegistry.getFields(objectName), metadata),
168
+ outputSchema: this.buildOutputSchema(objectName, methodName, ObjectRegistry.getFields(objectName))
231
169
  };
232
170
  }
233
171
  resolveCustomActionMetadata(objectName, action, method, collectionReceiver = false) {
@@ -254,45 +192,193 @@ var MCPGenerator = class {
254
192
  return false;
255
193
  }
256
194
  }
195
+ /** Normalize registry fields for the transport-neutral schema emitter. */
196
+ toToolFields(fields) {
197
+ return Array.from(fields, ([name, field]) => ({
198
+ name,
199
+ type: field.type,
200
+ required: field.required ?? field._meta?.required,
201
+ nullable: field._meta?.nullable === true,
202
+ description: typeof field._meta?.description === "string" ? field._meta.description : void 0,
203
+ default: field._meta?.default,
204
+ maxLength: field._meta?.maxLength,
205
+ minLength: field._meta?.minLength,
206
+ min: field._meta?.min,
207
+ max: field._meta?.max,
208
+ related: field.related
209
+ }));
210
+ }
257
211
  /**
258
- * Convert field definition to MCP schema
212
+ * Add the optional STI discriminator branches to write schemas. The legacy
213
+ * branch preserves existing base-class creates that let SMRT pick the base
214
+ * type; an explicit `_meta_type` selects a known child collection below.
259
215
  */
260
- fieldToMCPSchema(field) {
261
- const schema = { description: field._meta?.description || `${field.type} field` };
262
- switch (field.type) {
263
- case "text":
264
- schema.type = "string";
265
- if (field._meta?.maxLength) schema.maxLength = field._meta.maxLength;
266
- if (field._meta?.minLength) schema.minLength = field._meta.minLength;
267
- break;
268
- case "integer":
269
- schema.type = "integer";
270
- if (field._meta?.min !== void 0) schema.minimum = field._meta.min;
271
- if (field._meta?.max !== void 0) schema.maximum = field._meta.max;
272
- break;
273
- case "decimal":
274
- schema.type = "number";
275
- if (field._meta?.min !== void 0) schema.minimum = field._meta.min;
276
- if (field._meta?.max !== void 0) schema.maximum = field._meta.max;
277
- break;
278
- case "boolean":
279
- schema.type = "boolean";
280
- break;
281
- case "datetime":
282
- schema.type = "string";
283
- schema.format = "date-time";
284
- break;
285
- case "json":
286
- schema.type = "object";
287
- break;
288
- case "foreignKey":
289
- schema.type = "string";
290
- if (!field._meta?.description) schema.description = `ID of related ${field.related || "object"}`;
291
- break;
292
- default: schema.type = "string";
216
+ buildInputSchema(objectName, action, fields, customAction) {
217
+ const schema = buildToolInputSchema(action, this.toToolFields(fields), customAction, ObjectRegistry.getConfig(objectName).idType);
218
+ if (action !== "create" && action !== "update") return schema;
219
+ const variants = this.getStiVariants(objectName);
220
+ if (variants.length === 0) return schema;
221
+ const properties = {
222
+ ...schema.properties ?? {},
223
+ _meta_type: {
224
+ type: "string",
225
+ description: "Optional STI discriminator. When provided for create, selects the declared subtype."
226
+ }
227
+ };
228
+ return finalizeMcpJsonSchema({
229
+ ...schema,
230
+ properties,
231
+ oneOf: [{ not: { required: ["_meta_type"] } }, ...variants.map(({ name, discriminator }) => {
232
+ const variantFields = ObjectRegistry.getFields(name);
233
+ return {
234
+ properties: {
235
+ ...this.buildFieldSchemaProperties(variantFields),
236
+ _meta_type: { const: discriminator }
237
+ },
238
+ required: ["_meta_type", ...this.toToolFields(variantFields).filter((field) => field.required).map((field) => field.name)]
239
+ };
240
+ })]
241
+ });
242
+ }
243
+ /**
244
+ * Public output schemas follow the actual `toPublicJSON()` boundary: known
245
+ * non-sensitive fields are described, while `additionalProperties` keeps
246
+ * framework/system fields and application transforms honest.
247
+ */
248
+ buildOutputSchema(objectName, action, fields) {
249
+ const errorSchema = {
250
+ type: "object",
251
+ properties: { error: {
252
+ type: "object",
253
+ additionalProperties: true
254
+ } },
255
+ required: ["error"]
256
+ };
257
+ if (![
258
+ "list",
259
+ "get",
260
+ "create",
261
+ "update",
262
+ "delete"
263
+ ].includes(action)) return finalizeMcpJsonSchema({
264
+ type: "object",
265
+ anyOf: [{
266
+ type: "object",
267
+ properties: { data: {} },
268
+ required: ["data"]
269
+ }, { $ref: "#/$defs/error" }],
270
+ $defs: { error: errorSchema }
271
+ });
272
+ const itemSchema = this.buildPublicItemSchema(objectName, fields);
273
+ if (action === "list") return finalizeMcpJsonSchema({
274
+ type: "object",
275
+ anyOf: [{
276
+ type: "object",
277
+ properties: {
278
+ data: {
279
+ type: "array",
280
+ items: { $ref: "#/$defs/publicItem" }
281
+ },
282
+ meta: {
283
+ type: "object",
284
+ properties: {
285
+ total: {
286
+ type: "integer",
287
+ minimum: 0
288
+ },
289
+ limit: {
290
+ type: "integer",
291
+ minimum: 0
292
+ },
293
+ offset: {
294
+ type: "integer",
295
+ minimum: 0
296
+ },
297
+ count: {
298
+ type: "integer",
299
+ minimum: 0
300
+ }
301
+ },
302
+ required: [
303
+ "total",
304
+ "limit",
305
+ "offset",
306
+ "count"
307
+ ]
308
+ }
309
+ },
310
+ required: ["data", "meta"]
311
+ }, { $ref: "#/$defs/error" }],
312
+ $defs: {
313
+ publicItem: itemSchema,
314
+ error: errorSchema
315
+ }
316
+ });
317
+ if (action === "delete") return finalizeMcpJsonSchema({
318
+ type: "object",
319
+ anyOf: [{
320
+ type: "object",
321
+ properties: {
322
+ success: { const: true },
323
+ message: { type: "string" }
324
+ },
325
+ required: ["success", "message"]
326
+ }, { $ref: "#/$defs/error" }],
327
+ $defs: { error: errorSchema }
328
+ });
329
+ return finalizeMcpJsonSchema({
330
+ type: "object",
331
+ anyOf: [itemSchema, { $ref: "#/$defs/error" }],
332
+ $defs: { error: errorSchema }
333
+ });
334
+ }
335
+ buildPublicItemSchema(objectName, fields) {
336
+ const properties = this.buildFieldSchemaProperties(fields, true);
337
+ const variants = this.getStiVariants(objectName);
338
+ if (variants.length > 0) return { oneOf: variants.map(({ name, discriminator }) => ({
339
+ type: "object",
340
+ properties: {
341
+ ...this.buildFieldSchemaProperties(ObjectRegistry.getFields(name), true),
342
+ _meta_type: { const: discriminator }
343
+ },
344
+ required: ["_meta_type"],
345
+ additionalProperties: true
346
+ })) };
347
+ return {
348
+ type: "object",
349
+ properties,
350
+ additionalProperties: true
351
+ };
352
+ }
353
+ buildFieldSchemaProperties(fields, publicOnly = false) {
354
+ const properties = {};
355
+ for (const [name, field] of fields) {
356
+ if (publicOnly && (field._meta?.sensitive === true || field._meta?.transient === true)) continue;
357
+ const [toolField] = this.toToolFields(/* @__PURE__ */ new Map([[name, field]]));
358
+ if (!toolField) continue;
359
+ properties[name] = { ...fieldTypeToJsonSchema(toolField) };
293
360
  }
294
- if (field._meta?.default !== void 0) schema.default = field._meta.default;
295
- return schema;
361
+ return properties;
362
+ }
363
+ getStiVariants(objectName) {
364
+ if (ObjectRegistry.getTableStrategy(objectName) !== "sti") return [];
365
+ const base = ObjectRegistry.getClass(objectName);
366
+ const baseNames = new Set([
367
+ objectName,
368
+ base?.name,
369
+ base?.qualifiedName
370
+ ].filter((name) => typeof name === "string"));
371
+ const variants = /* @__PURE__ */ new Map();
372
+ for (const [key, info] of ObjectRegistry.getAllClasses()) {
373
+ const name = info.name || key;
374
+ if (!ObjectRegistry.getInheritanceChain(name).some((ancestor) => baseNames.has(ancestor))) continue;
375
+ const discriminator = info.qualifiedName || name;
376
+ variants.set(discriminator, {
377
+ name,
378
+ discriminator
379
+ });
380
+ }
381
+ return Array.from(variants.values()).sort((left, right) => left.discriminator.localeCompare(right.discriminator));
296
382
  }
297
383
  /**
298
384
  * Handle MCP tool calls
@@ -319,25 +405,56 @@ var MCPGenerator = class {
319
405
  if (!classInfo) throw new Error(`Object type '${objectName}' not found`);
320
406
  const collection = await this.getCollection(actualObjectName, classInfo);
321
407
  const result = await this.executeAction(collection, action, args, actualObjectName);
322
- return { content: [{
323
- type: "text",
324
- text: JSON.stringify(result, null, 2)
325
- }] };
408
+ const publicResult = this.toJsonValue(result);
409
+ const structuredContent = this.toStructuredContent(action, publicResult);
410
+ return {
411
+ content: [{
412
+ type: "text",
413
+ text: JSON.stringify(publicResult, null, 2)
414
+ }],
415
+ structuredContent
416
+ };
326
417
  } catch (error) {
327
- if (error instanceof CustomActionFailureError) return {
418
+ if (error instanceof CustomActionFailureError) {
419
+ const structuredContent = { error: error.failure };
420
+ return {
421
+ content: [{
422
+ type: "text",
423
+ text: JSON.stringify(structuredContent)
424
+ }],
425
+ isError: true,
426
+ structuredContent,
427
+ _meta: { [SMRT_CUSTOM_ACTION_ERROR_METADATA_KEY]: error.failure }
428
+ };
429
+ }
430
+ const message = error instanceof Error ? error.message : "Unknown error";
431
+ return {
328
432
  content: [{
329
433
  type: "text",
330
- text: JSON.stringify({ error: error.failure })
434
+ text: `Error: ${message}`
331
435
  }],
332
436
  isError: true,
333
- _meta: { [SMRT_CUSTOM_ACTION_ERROR_METADATA_KEY]: error.failure }
437
+ structuredContent: { error: { message } }
334
438
  };
335
- return { content: [{
336
- type: "text",
337
- text: `Error: ${error instanceof Error ? error.message : "Unknown error"}`
338
- }] };
339
439
  }
340
440
  }
441
+ /** Convert runtime values to the JSON values MCP structuredContent permits. */
442
+ toJsonValue(value) {
443
+ const serialized = JSON.stringify(value);
444
+ return serialized === void 0 ? null : JSON.parse(serialized);
445
+ }
446
+ /** Build the MCP-required object root without changing legacy text payloads. */
447
+ toStructuredContent(action, publicResult) {
448
+ if (![
449
+ "list",
450
+ "get",
451
+ "create",
452
+ "update",
453
+ "delete"
454
+ ].includes(action)) return { data: publicResult };
455
+ if (publicResult === null || typeof publicResult !== "object" || Array.isArray(publicResult)) throw new Error(`Expected object result for MCP ${action} action`);
456
+ return publicResult;
457
+ }
341
458
  /**
342
459
  * Get or create collection for an object
343
460
  */
@@ -433,14 +550,24 @@ var MCPGenerator = class {
433
550
  if (!this.context.user) throw new Error("Authentication required");
434
551
  }
435
552
  async executeAction(collection, action, args, objectName) {
553
+ let targetCollection = collection;
554
+ let targetObjectName = objectName;
555
+ if (action === "create" && objectName && typeof args._meta_type === "string") {
556
+ const variant = this.getStiVariants(objectName).find((candidate) => candidate.discriminator === args._meta_type);
557
+ if (!variant) throw new Error(`Unknown STI discriminator: ${args._meta_type}`);
558
+ const classInfo = ObjectRegistry.getClass(variant.name);
559
+ if (!classInfo) throw new Error(`STI subtype '${variant.name}' is not registered`);
560
+ targetCollection = await this.getCollection(variant.name, classInfo);
561
+ targetObjectName = variant.name;
562
+ }
436
563
  const mutating = action !== "list" && action !== "get";
437
- this.requireToolAuth(objectName, mutating);
564
+ this.requireToolAuth(targetObjectName, mutating);
438
565
  return runWithTenantGate({
439
- className: objectName,
566
+ className: targetObjectName,
440
567
  tenantId: this.context.tenantId,
441
568
  allowCrossTenant: this.context.allowCrossTenant,
442
569
  surface: "MCP"
443
- }, () => this.runAction(collection, action, args, objectName));
570
+ }, () => this.runAction(targetCollection, action, args, targetObjectName));
444
571
  }
445
572
  /**
446
573
  * Derive the set of tenant-scoped object names (lowercased simple names) from
@@ -647,7 +774,8 @@ var MCPGenerator = class {
647
774
  debug,
648
775
  tools,
649
776
  customActions: await this.runtimeCustomActions(tools),
650
- tenantScopedObjects: await this.tenantScopedObjectNames(tools)
777
+ tenantScopedObjects: await this.tenantScopedObjectNames(tools),
778
+ stiTargets: this.runtimeStiTargets(tools)
651
779
  }), "utf-8");
652
780
  console.log(`✅ Generated MCP server: ${resolvedPath}`);
653
781
  }
@@ -703,6 +831,28 @@ var MCPGenerator = class {
703
831
  return metadata;
704
832
  }
705
833
  /**
834
+ * Emit only the STI discriminator targets advertised by create-tool schemas.
835
+ * Generated processes start with an empty registry, so resolving the
836
+ * qualified target through `getCollection()` both validates the declaration
837
+ * and lets the public registry loader register the selected subtype.
838
+ */
839
+ runtimeStiTargets(tools) {
840
+ const targets = {};
841
+ const classes = ObjectRegistry.getAllClasses();
842
+ for (const tool of tools) {
843
+ const separator = tool.name.indexOf("_");
844
+ if (separator === -1 || tool.name.slice(separator + 1) !== "create") continue;
845
+ const objectPrefix = tool.name.slice(0, separator);
846
+ const matched = Array.from(classes.entries()).find(([key, info]) => (info.name || key).toLowerCase() === objectPrefix);
847
+ if (!matched) continue;
848
+ const [key, classInfo] = matched;
849
+ const variants = this.getStiVariants(classInfo.name || key);
850
+ if (variants.length === 0) continue;
851
+ targets[objectPrefix] = Object.fromEntries(variants.map((variant) => [variant.discriminator, variant.discriminator]));
852
+ }
853
+ return targets;
854
+ }
855
+ /**
706
856
  * Generate modular MCP server structure
707
857
  *
708
858
  * Creates separate files for tools, handlers, configuration, and main entry point.
@@ -761,14 +911,15 @@ export const tools: Array<{
761
911
  name: string;
762
912
  description: string;
763
913
  inputSchema: any;
914
+ outputSchema: any;
764
915
  }> = ${JSON.stringify(tools, null, 2)};
765
916
  `;
766
917
  }
767
918
  /**
768
919
  * Generate switch cases for tool execution
769
920
  */
770
- async generateToolSwitchCases(indent = " ") {
771
- const tools = await this.generateTools();
921
+ async generateToolSwitchCases(indent = " ", generatedTools) {
922
+ const tools = generatedTools ?? await this.generateTools();
772
923
  const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
773
924
  return (await Promise.all(tools.map(async (tool) => {
774
925
  const separator = tool.name.indexOf("_");
@@ -781,13 +932,17 @@ ${indent} const offset = args.offset ?? 0;
781
932
  ${indent} const where = args.where ?? {};
782
933
 
783
934
  ${indent} const collection = await ObjectRegistry.getCollection('${capitalize(objectName)}', {
784
- ${indent} persistence: { type: 'sql', url: process.env.DATABASE_URL || ':memory:' },
935
+ ${indent} persistence: { type: process.env.DATABASE_TYPE || 'sqlite', url: process.env.DATABASE_URL || ':memory:' },
785
936
  ${indent} ai: aiConfig
786
937
  ${indent} });
787
938
 
788
939
  ${indent} const items = await collection.list({ where, limit, offset });
789
940
  ${indent} const itemsPublic = items.map((item) => item.toPublicJSON(PUBLIC_JSON_OPTIONS));
790
- ${indent} return { content: [{ type: 'text', text: JSON.stringify(itemsPublic) }] };
941
+ ${indent} const structuredContent = {
942
+ ${indent} data: itemsPublic,
943
+ ${indent} meta: { total: await collection.count({ where }), limit, offset, count: items.length },
944
+ ${indent} };
945
+ ${indent} return successResult(structuredContent, JSON.stringify(itemsPublic));
791
946
  ${indent}}`;
792
947
  case "get": return `${indent}case '${tool.name}': {
793
948
  ${indent} if (!args.id && !args.slug) {
@@ -795,7 +950,7 @@ ${indent} throw new Error('Either id or slug is required');
795
950
  ${indent} }
796
951
 
797
952
  ${indent} const collection = await ObjectRegistry.getCollection('${capitalize(objectName)}', {
798
- ${indent} persistence: { type: 'sql', url: process.env.DATABASE_URL || ':memory:' },
953
+ ${indent} persistence: { type: process.env.DATABASE_TYPE || 'sqlite', url: process.env.DATABASE_URL || ':memory:' },
799
954
  ${indent} ai: aiConfig
800
955
  ${indent} });
801
956
 
@@ -806,18 +961,15 @@ ${indent} if (!item) {
806
961
  ${indent} throw new Error('Object not found');
807
962
  ${indent} }
808
963
 
809
- ${indent} return { content: [{ type: 'text', text: JSON.stringify(item.toPublicJSON(PUBLIC_JSON_OPTIONS)) }] };
964
+ ${indent} return successResult(item.toPublicJSON(PUBLIC_JSON_OPTIONS));
810
965
  ${indent}}`;
811
966
  case "create": return `${indent}case '${tool.name}': {
812
- ${indent} const collection = await ObjectRegistry.getCollection('${capitalize(objectName)}', {
813
- ${indent} persistence: { type: 'sql', url: process.env.DATABASE_URL || ':memory:' },
814
- ${indent} ai: aiConfig
815
- ${indent} });
967
+ ${indent} const { collection, objectName: targetObjectName } = await resolveCreateTarget('${objectName}', args, aiConfig);
816
968
 
817
- ${indent} const newItem = await collection.create(applyWritablePolicy('${capitalize(objectName)}', args));
969
+ ${indent} const newItem = await collection.create(applyWritablePolicy(targetObjectName, args));
818
970
  ${indent} await newItem.save();
819
971
 
820
- ${indent} return { content: [{ type: 'text', text: JSON.stringify(newItem.toPublicJSON(PUBLIC_JSON_OPTIONS)) }] };
972
+ ${indent} return successResult(newItem.toPublicJSON(PUBLIC_JSON_OPTIONS));
821
973
  ${indent}}`;
822
974
  case "update": return `${indent}case '${tool.name}': {
823
975
  ${indent} const { id, ...updateData } = args;
@@ -826,7 +978,7 @@ ${indent} throw new Error('ID is required for update');
826
978
  ${indent} }
827
979
 
828
980
  ${indent} const collection = await ObjectRegistry.getCollection('${capitalize(objectName)}', {
829
- ${indent} persistence: { type: 'sql', url: process.env.DATABASE_URL || ':memory:' },
981
+ ${indent} persistence: { type: process.env.DATABASE_TYPE || 'sqlite', url: process.env.DATABASE_URL || ':memory:' },
830
982
  ${indent} ai: aiConfig
831
983
  ${indent} });
832
984
 
@@ -838,7 +990,7 @@ ${indent} }
838
990
  ${indent} Object.assign(existing, applyWritablePolicy('${capitalize(objectName)}', updateData));
839
991
  ${indent} await existing.save();
840
992
 
841
- ${indent} return { content: [{ type: 'text', text: JSON.stringify(existing.toPublicJSON(PUBLIC_JSON_OPTIONS)) }] };
993
+ ${indent} return successResult(existing.toPublicJSON(PUBLIC_JSON_OPTIONS));
842
994
  ${indent}}`;
843
995
  case "delete": return `${indent}case '${tool.name}': {
844
996
  ${indent} if (!args.id) {
@@ -846,7 +998,7 @@ ${indent} throw new Error('ID is required for delete');
846
998
  ${indent} }
847
999
 
848
1000
  ${indent} const collection = await ObjectRegistry.getCollection('${capitalize(objectName)}', {
849
- ${indent} persistence: { type: 'sql', url: process.env.DATABASE_URL || ':memory:' },
1001
+ ${indent} persistence: { type: process.env.DATABASE_TYPE || 'sqlite', url: process.env.DATABASE_URL || ':memory:' },
850
1002
  ${indent} ai: aiConfig
851
1003
  ${indent} });
852
1004
 
@@ -857,7 +1009,7 @@ ${indent} }
857
1009
 
858
1010
  ${indent} await toDelete.delete();
859
1011
 
860
- ${indent} return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: 'Object deleted successfully' }) }] };
1012
+ ${indent} return successResult({ success: true, message: 'Object deleted successfully' });
861
1013
  ${indent}}`;
862
1014
  default: {
863
1015
  const matched = Array.from(ObjectRegistry.getAllClasses().entries()).find(([key, info]) => (info.name || key).toLowerCase() === objectName.toLowerCase());
@@ -878,7 +1030,7 @@ ${indent} throw new Error('Custom action ${action} is collection-scoped and d
878
1030
  ${indent} }
879
1031
 
880
1032
  ${indent} const collection = await ObjectRegistry.getCollection(${JSON.stringify(registeredName)}, {
881
- ${indent} persistence: { type: 'sql', url: process.env.DATABASE_URL || ':memory:' },
1033
+ ${indent} persistence: { type: process.env.DATABASE_TYPE || 'sqlite', url: process.env.DATABASE_URL || ':memory:' },
882
1034
  ${indent} ai: aiConfig
883
1035
  ${indent} });
884
1036
 
@@ -900,14 +1052,15 @@ ${indent} const methodArgs = ${methodArgs.startsWith("[") ? methodArgs : `[${me
900
1052
  ${indent} const result = await actionMethod.call(target, ...methodArgs);
901
1053
  ${indent} const failure = normalizeCustomActionFailure(result);
902
1054
  ${indent} if (failure) {
903
- ${indent} return {
904
- ${indent} content: [{ type: 'text', text: JSON.stringify({ error: failure }) }],
905
- ${indent} isError: true,
906
- ${indent} _meta: { [SMRT_CUSTOM_ACTION_ERROR_METADATA_KEY]: failure },
907
- ${indent} };
1055
+ ${indent} return errorResult(
1056
+ ${indent} { error: failure },
1057
+ ${indent} JSON.stringify({ error: failure }),
1058
+ ${indent} { [SMRT_CUSTOM_ACTION_ERROR_METADATA_KEY]: failure },
1059
+ ${indent} );
908
1060
  ${indent} }
909
1061
 
910
- ${indent} return { content: [{ type: 'text', text: JSON.stringify(toPublicResult(result)) }] };
1062
+ ${indent} const publicResult = toPublicResult(result);
1063
+ ${indent} return successResult({ data: publicResult }, JSON.stringify(publicResult));
911
1064
  ${indent}}`;
912
1065
  }
913
1066
  }
@@ -917,7 +1070,9 @@ ${indent}}`;
917
1070
  * Generate handlers file for modular server
918
1071
  */
919
1072
  async generateHandlersFile(tenantScopedObjects = []) {
920
- const switchCases = await this.generateToolSwitchCases(" ");
1073
+ const tools = await this.generateTools();
1074
+ const switchCases = await this.generateToolSwitchCases(" ", tools);
1075
+ const stiTargets = this.runtimeStiTargets(tools);
921
1076
  const tenantScopedSet = Array.from(new Set(tenantScopedObjects.map((n) => n.toLowerCase())));
922
1077
  const hasTenantScoped = tenantScopedSet.length > 0;
923
1078
  return `/**
@@ -950,6 +1105,7 @@ const PUBLIC_JSON_OPTIONS = {
950
1105
  .map((permission) => permission.trim())
951
1106
  .filter(Boolean),
952
1107
  };
1108
+ const STI_TARGETS: Record<string, Record<string, string>> = ${JSON.stringify(stiTargets)};
953
1109
 
954
1110
  /**
955
1111
  * Mass-assignment guard (#1540): strip framework/server-managed and
@@ -984,6 +1140,23 @@ function applyWritablePolicy(objectName: string, data: any): Record<string, any>
984
1140
  return result;
985
1141
  }
986
1142
 
1143
+ /** Resolve an advertised STI discriminator to its registered subtype collection. */
1144
+ async function resolveCreateTarget(baseObjectName: string, args: Record<string, any>, aiConfig: any) {
1145
+ let objectName = baseObjectName;
1146
+ const discriminator = args._meta_type;
1147
+ const targets = STI_TARGETS[baseObjectName];
1148
+ if (typeof discriminator === 'string' && targets) {
1149
+ const target = targets[discriminator];
1150
+ if (!target) throw new Error('Unknown STI discriminator: ' + discriminator);
1151
+ objectName = target;
1152
+ }
1153
+ const collection = await ObjectRegistry.getCollection(objectName, {
1154
+ persistence: { type: process.env.DATABASE_TYPE || 'sqlite', url: process.env.DATABASE_URL || ':memory:' },
1155
+ ai: aiConfig,
1156
+ });
1157
+ return { collection, objectName };
1158
+ }
1159
+
987
1160
  /**
988
1161
  * Sensitive-field-safe serialization for custom-action results (#1540).
989
1162
  * Recurses through arrays and plain objects so nested SmrtObjects are stripped
@@ -1008,6 +1181,22 @@ function toPublicResult(value: any, seen: WeakSet<object> = new WeakSet()): any
1008
1181
  return out;
1009
1182
  }
1010
1183
 
1184
+ function successResult(structuredContent: any, text = JSON.stringify(structuredContent)) {
1185
+ return {
1186
+ content: [{ type: 'text', text }],
1187
+ structuredContent,
1188
+ };
1189
+ }
1190
+
1191
+ function errorResult(structuredContent: any, text: string, _meta?: Record<string, any>) {
1192
+ return {
1193
+ content: [{ type: 'text', text }],
1194
+ isError: true,
1195
+ structuredContent,
1196
+ ...(_meta ? { _meta } : {}),
1197
+ };
1198
+ }
1199
+
1011
1200
  /**
1012
1201
  * Handle tool call request
1013
1202
  */
@@ -1043,15 +1232,10 @@ ${hasTenantScoped ? `
1043
1232
  } catch (error) {
1044
1233
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
1045
1234
 
1046
- return {
1047
- content: [
1048
- {
1049
- type: 'text',
1050
- text: \`Error executing tool \${name}: \${errorMessage}\`,
1051
- },
1052
- ],
1053
- isError: true,
1054
- };
1235
+ return errorResult(
1236
+ { error: { message: errorMessage } },
1237
+ \`Error executing tool \${name}: \${errorMessage}\`,
1238
+ );
1055
1239
  }
1056
1240
  }
1057
1241
  `;
@@ -1070,7 +1254,10 @@ ${hasTenantScoped ? `
1070
1254
 
1071
1255
  import { Server } from '@modelcontextprotocol/server';
1072
1256
  import { serveStdio } from '@modelcontextprotocol/server/stdio';
1257
+ import { existsSync } from 'node:fs';
1258
+ import { resolve } from 'node:path';
1073
1259
  import { pathToFileURL } from 'node:url';
1260
+ import { ObjectRegistry } from '@happyvertical/smrt-core';
1074
1261
  import { loadConfig } from '@happyvertical/smrt-config';
1075
1262
  import { getDatabase } from '@happyvertical/sql';
1076
1263
  import { getAI } from '@happyvertical/ai';
@@ -1088,6 +1275,17 @@ export async function createServer() {
1088
1275
  console.error(\`[MCP] Available tools:\`, tools.map(t => t.name).join(', '));
1089
1276
  }
1090
1277
 
1278
+ // Register the application package manifest before resolving generated
1279
+ // object names. Generated servers are commonly run from the application
1280
+ // package itself, which is not a node_modules dependency of its process.
1281
+ const localManifestPaths = [
1282
+ resolve(process.cwd(), 'dist', 'manifest.json'),
1283
+ resolve(process.cwd(), '.smrt', 'manifest.json'),
1284
+ ].filter(existsSync);
1285
+ if (localManifestPaths.length > 0) {
1286
+ ObjectRegistry.loadAllManifests({ manifestPaths: localManifestPaths });
1287
+ }
1288
+
1091
1289
  // Load configuration from environment and .smrt.config files
1092
1290
  const appConfig = await loadConfig();
1093
1291
  const aiConfig = appConfig?.ai || {};
@@ -1116,6 +1314,7 @@ export async function createServer() {
1116
1314
  name: tool.name,
1117
1315
  description: tool.description,
1118
1316
  inputSchema: tool.inputSchema,
1317
+ outputSchema: tool.outputSchema,
1119
1318
  })),
1120
1319
  };
1121
1320
  });