@harperfast/harper 5.2.0-alpha.4 → 5.2.0-alpha.6

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.
Files changed (173) hide show
  1. package/agent/agent.ts +152 -10
  2. package/agent/bestPractices.ts +58 -0
  3. package/agent/mcpTools.ts +122 -0
  4. package/agent/operations.ts +10 -1
  5. package/agent/registryTools.ts +117 -0
  6. package/agent/session.ts +16 -7
  7. package/agent/tools/fsTools.ts +74 -27
  8. package/agent/tools/inspectorTool.ts +459 -0
  9. package/agent/toolset.ts +27 -7
  10. package/agent/types.ts +2 -0
  11. package/components/Application.ts +566 -85
  12. package/components/OptionsWatcher.ts +62 -2
  13. package/components/Scope.ts +31 -8
  14. package/components/componentLoader.ts +5 -1
  15. package/components/componentSecrets.ts +514 -55
  16. package/components/deploymentRecorder.ts +7 -6
  17. package/components/gitCredentialHelper.js +115 -0
  18. package/components/gitCredentialServer.ts +251 -0
  19. package/components/mcp/tools/application.ts +197 -23
  20. package/components/mcp/tools/operations.ts +1 -1
  21. package/components/mcp/tools/schemas/derive.ts +6 -2
  22. package/components/operations.js +21 -19
  23. package/components/operationsValidation.js +86 -21
  24. package/components/secretOperations.ts +110 -30
  25. package/config/harperConfigEnvVars.ts +90 -5
  26. package/config-root.schema.json +4 -0
  27. package/dist/agent/agent.d.ts +24 -3
  28. package/dist/agent/agent.js +172 -10
  29. package/dist/agent/agent.js.map +1 -1
  30. package/dist/agent/bestPractices.d.ts +24 -0
  31. package/dist/agent/bestPractices.js +60 -0
  32. package/dist/agent/bestPractices.js.map +1 -0
  33. package/dist/agent/mcpTools.d.ts +24 -0
  34. package/dist/agent/mcpTools.js +115 -0
  35. package/dist/agent/mcpTools.js.map +1 -0
  36. package/dist/agent/operations.js +10 -1
  37. package/dist/agent/operations.js.map +1 -1
  38. package/dist/agent/registryTools.d.ts +45 -0
  39. package/dist/agent/registryTools.js +113 -0
  40. package/dist/agent/registryTools.js.map +1 -0
  41. package/dist/agent/session.js +16 -7
  42. package/dist/agent/session.js.map +1 -1
  43. package/dist/agent/tools/fsTools.js +70 -28
  44. package/dist/agent/tools/fsTools.js.map +1 -1
  45. package/dist/agent/tools/inspectorTool.d.ts +40 -0
  46. package/dist/agent/tools/inspectorTool.js +428 -0
  47. package/dist/agent/tools/inspectorTool.js.map +1 -0
  48. package/dist/agent/toolset.d.ts +16 -6
  49. package/dist/agent/toolset.js +17 -7
  50. package/dist/agent/toolset.js.map +1 -1
  51. package/dist/agent/types.d.ts +2 -0
  52. package/dist/components/Application.d.ts +73 -18
  53. package/dist/components/Application.js +475 -64
  54. package/dist/components/Application.js.map +1 -1
  55. package/dist/components/OptionsWatcher.d.ts +1 -1
  56. package/dist/components/OptionsWatcher.js +65 -2
  57. package/dist/components/OptionsWatcher.js.map +1 -1
  58. package/dist/components/Scope.d.ts +8 -6
  59. package/dist/components/Scope.js +22 -6
  60. package/dist/components/Scope.js.map +1 -1
  61. package/dist/components/componentLoader.js +5 -1
  62. package/dist/components/componentLoader.js.map +1 -1
  63. package/dist/components/componentSecrets.d.ts +33 -7
  64. package/dist/components/componentSecrets.js +473 -59
  65. package/dist/components/componentSecrets.js.map +1 -1
  66. package/dist/components/deploymentRecorder.d.ts +2 -2
  67. package/dist/components/deploymentRecorder.js +1 -1
  68. package/dist/components/deploymentRecorder.js.map +1 -1
  69. package/dist/components/gitCredentialHelper.d.ts +1 -0
  70. package/dist/components/gitCredentialHelper.js +113 -0
  71. package/dist/components/gitCredentialHelper.js.map +1 -0
  72. package/dist/components/gitCredentialServer.d.ts +33 -0
  73. package/dist/components/gitCredentialServer.js +236 -0
  74. package/dist/components/gitCredentialServer.js.map +1 -0
  75. package/dist/components/mcp/tools/application.d.ts +19 -0
  76. package/dist/components/mcp/tools/application.js +180 -22
  77. package/dist/components/mcp/tools/application.js.map +1 -1
  78. package/dist/components/mcp/tools/operations.d.ts +13 -0
  79. package/dist/components/mcp/tools/operations.js +1 -0
  80. package/dist/components/mcp/tools/operations.js.map +1 -1
  81. package/dist/components/mcp/tools/schemas/derive.js +6 -2
  82. package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
  83. package/dist/components/operations.js +23 -21
  84. package/dist/components/operations.js.map +1 -1
  85. package/dist/components/operationsValidation.js +84 -21
  86. package/dist/components/operationsValidation.js.map +1 -1
  87. package/dist/components/secretOperations.d.ts +33 -11
  88. package/dist/components/secretOperations.js +90 -26
  89. package/dist/components/secretOperations.js.map +1 -1
  90. package/dist/config/harperConfigEnvVars.d.ts +21 -0
  91. package/dist/config/harperConfigEnvVars.js +95 -5
  92. package/dist/config/harperConfigEnvVars.js.map +1 -1
  93. package/dist/index.d.ts +4 -0
  94. package/dist/index.js +14 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/resources/Resource.d.ts +19 -0
  97. package/dist/resources/Resource.js +93 -2
  98. package/dist/resources/Resource.js.map +1 -1
  99. package/dist/resources/Table.d.ts +9 -0
  100. package/dist/resources/Table.js +256 -50
  101. package/dist/resources/Table.js.map +1 -1
  102. package/dist/resources/analytics/read.js +28 -25
  103. package/dist/resources/analytics/read.js.map +1 -1
  104. package/dist/resources/defineResource.d.ts +180 -0
  105. package/dist/resources/defineResource.js +505 -0
  106. package/dist/resources/defineResource.js.map +1 -0
  107. package/dist/resources/defineTable.d.ts +221 -0
  108. package/dist/resources/defineTable.js +227 -0
  109. package/dist/resources/defineTable.js.map +1 -0
  110. package/dist/resources/jsonSchemaTypes.d.ts +2 -0
  111. package/dist/resources/jsonSchemaTypes.js +12 -4
  112. package/dist/resources/jsonSchemaTypes.js.map +1 -1
  113. package/dist/resources/openApi.js +69 -13
  114. package/dist/resources/openApi.js.map +1 -1
  115. package/dist/resources/search.js +6 -8
  116. package/dist/resources/search.js.map +1 -1
  117. package/dist/server/graphqlQuerying.js +4 -2
  118. package/dist/server/graphqlQuerying.js.map +1 -1
  119. package/dist/server/http.d.ts +12 -0
  120. package/dist/server/http.js +35 -15
  121. package/dist/server/http.js.map +1 -1
  122. package/dist/server/serverHelpers/serverUtilities.js +8 -5
  123. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  124. package/dist/server/serverHelpers/uwsServer.js +23 -0
  125. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  126. package/dist/utility/errors/hdbError.d.ts +21 -0
  127. package/dist/utility/errors/hdbError.js +23 -1
  128. package/dist/utility/errors/hdbError.js.map +1 -1
  129. package/dist/validation/configValidator.js +22 -6
  130. package/dist/validation/configValidator.js.map +1 -1
  131. package/index.ts +33 -0
  132. package/npm-shrinkwrap.json +9197 -15236
  133. package/package.json +5 -3
  134. package/resources/DESIGN.md +42 -15
  135. package/resources/Resource.ts +99 -2
  136. package/resources/Table.ts +275 -71
  137. package/resources/analytics/read.ts +30 -25
  138. package/resources/defineResource.ts +651 -0
  139. package/resources/defineTable.ts +407 -0
  140. package/resources/jsonSchemaTypes.ts +12 -4
  141. package/resources/openApi.ts +68 -16
  142. package/resources/search.ts +5 -8
  143. package/server/graphqlQuerying.ts +4 -2
  144. package/server/http.ts +40 -16
  145. package/server/serverHelpers/serverUtilities.ts +19 -6
  146. package/server/serverHelpers/uwsServer.ts +24 -0
  147. package/studio/web/assets/{Chat-D4FIaBu7.js → Chat-CTjtL8Z4.js} +2 -2
  148. package/studio/web/assets/{Chat-D4FIaBu7.js.map → Chat-CTjtL8Z4.js.map} +1 -1
  149. package/studio/web/assets/{FloatingChat-DGUC3L4r.js → FloatingChat-CafHR4Ur.js} +4 -4
  150. package/studio/web/assets/{FloatingChat-DGUC3L4r.js.map → FloatingChat-CafHR4Ur.js.map} +1 -1
  151. package/studio/web/assets/{applications-D8Am9ikM.js → applications-Buh_q0Vj.js} +2 -2
  152. package/studio/web/assets/{applications-D8Am9ikM.js.map → applications-Buh_q0Vj.js.map} +1 -1
  153. package/studio/web/assets/{index-HGL4WHdb.js → index-0hXeECkS.js} +6 -6
  154. package/studio/web/assets/{index-HGL4WHdb.js.map → index-0hXeECkS.js.map} +1 -1
  155. package/studio/web/assets/{index.lazy-Dk5j1AoQ.js → index.lazy-B00B7VBT.js} +4 -4
  156. package/studio/web/assets/{index.lazy-Dk5j1AoQ.js.map → index.lazy-B00B7VBT.js.map} +1 -1
  157. package/studio/web/assets/{profile-7bu_CF1f.js → profile-Cg2wwYPn.js} +2 -2
  158. package/studio/web/assets/{profile-7bu_CF1f.js.map → profile-Cg2wwYPn.js.map} +1 -1
  159. package/studio/web/assets/{setComponentFile-MfYm9F94.js → setComponentFile-DCaDIvyB.js} +2 -2
  160. package/studio/web/assets/{setComponentFile-MfYm9F94.js.map → setComponentFile-DCaDIvyB.js.map} +1 -1
  161. package/studio/web/assets/{setup-SKA4UhdE.js → setup-CAVcAQjK.js} +2 -2
  162. package/studio/web/assets/{setup-SKA4UhdE.js.map → setup-CAVcAQjK.js.map} +1 -1
  163. package/studio/web/assets/{status-DetVLUxR.js → status-BRXorNdD.js} +2 -2
  164. package/studio/web/assets/{status-DetVLUxR.js.map → status-BRXorNdD.js.map} +1 -1
  165. package/studio/web/assets/{swagger-ui-react-DLeYARY2.js → swagger-ui-react-Dy1D62vO.js} +2 -2
  166. package/studio/web/assets/{swagger-ui-react-DLeYARY2.js.map → swagger-ui-react-Dy1D62vO.js.map} +1 -1
  167. package/studio/web/assets/{tsMode-DGO-jIF7.js → tsMode-A8gbL74v.js} +2 -2
  168. package/studio/web/assets/{tsMode-DGO-jIF7.js.map → tsMode-A8gbL74v.js.map} +1 -1
  169. package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js → useEntityRestURL-cDodrVcQ.js} +2 -2
  170. package/studio/web/assets/{useEntityRestURL-4M2i6bo4.js.map → useEntityRestURL-cDodrVcQ.js.map} +1 -1
  171. package/studio/web/index.html +1 -1
  172. package/utility/errors/hdbError.ts +32 -0
  173. package/validation/configValidator.ts +23 -6
@@ -84,6 +84,12 @@ interface ResourceClassLike {
84
84
  hidden?: boolean;
85
85
  properties?: Record<string, unknown>;
86
86
  outputSchemas?: { [verb: string]: object };
87
+ /** The URL path (may carry `:param`/`*wildcard` segments), used to bind path params for contract resources. */
88
+ path?: string;
89
+ /** Present when built via `Resource.withSchema` (RFC 0001, Pillar 2) — drives typed tool schemas + binding. */
90
+ requestContract?: unknown;
91
+ /** Per-verb `{ query?, body? }` JSON-Schema fragments derived from the request contract. */
92
+ inputSchemas?: { [verb: string]: { query?: JsonSchemaFragmentLike; body?: JsonSchemaFragmentLike } };
87
93
  mcp?: { annotations?: { [verb: string]: ToolAnnotationsLike } };
88
94
  get?: (target: unknown, request: unknown, data?: unknown) => unknown;
89
95
  put?: (target: unknown, data: unknown, request: unknown) => unknown;
@@ -185,6 +191,119 @@ function isSimpleIdRoute(pattern: string): boolean {
185
191
  return paramCount === 1;
186
192
  }
187
193
 
194
+ /** A subset of `JsonSchemaFragment` (resources/jsonSchemaTypes.ts) — the shared IR request contracts reduce to. */
195
+ interface JsonSchemaFragmentLike {
196
+ type?: string | string[];
197
+ properties?: Record<string, unknown>;
198
+ required?: readonly string[];
199
+ items?: unknown;
200
+ enum?: readonly unknown[];
201
+ }
202
+
203
+ /** MCP verb → request-contract verb. `search` maps to `get`'s query grammar; others are 1:1. */
204
+ const CONTRACT_VERB: Record<string, 'get' | 'post' | 'put' | 'patch' | 'delete'> = {
205
+ get: 'get',
206
+ search: 'get',
207
+ create: 'post',
208
+ update: 'put',
209
+ patch: 'patch',
210
+ delete: 'delete',
211
+ };
212
+
213
+ /** Parse `:param`/`*wildcard` segments out of a resource path (mirrors the route matcher). */
214
+ function pathParamNames(path?: string): string[] {
215
+ if (!path) return [];
216
+ return path
217
+ .split('/')
218
+ .filter((segment) => segment.charAt(0) === ':' || segment.charAt(0) === '*')
219
+ .map((segment) => (segment.charAt(0) === '*' ? segment.slice(1) || 'wildcard' : segment.slice(1)));
220
+ }
221
+
222
+ /**
223
+ * Build an MCP tool input schema from a resource's request contract (RFC 0001, Pillar 2): the path
224
+ * params (always required strings) plus the verb's declared query and body fields, all off the shared
225
+ * JsonSchemaFragment. Returns undefined when there is nothing contract-derived to advertise (the caller
226
+ * then falls back to the generic table-attribute derivation).
227
+ */
228
+ function contractInputSchema(ResourceClass: ResourceClassLike, contractVerb: string): object | undefined {
229
+ const input = ResourceClass.inputSchemas?.[contractVerb];
230
+ const params = pathParamNames(ResourceClass.path);
231
+ if (!input?.query?.properties && !input?.body?.properties && params.length === 0) return undefined;
232
+ const properties: Record<string, unknown> = {};
233
+ const required: string[] = [];
234
+ for (const param of params) {
235
+ properties[param] = { type: 'string', description: `Path parameter :${param}.` };
236
+ required.push(param);
237
+ }
238
+ const merge = (fragment?: JsonSchemaFragmentLike) => {
239
+ if (!fragment?.properties) return;
240
+ for (const [key, sub] of Object.entries(fragment.properties)) properties[key] = sub;
241
+ for (const key of fragment.required ?? []) if (!required.includes(key)) required.push(key);
242
+ };
243
+ merge(input?.query);
244
+ merge(input?.body);
245
+ const schema: { type: string; properties: Record<string, unknown>; required?: string[] } = {
246
+ type: 'object',
247
+ properties,
248
+ };
249
+ if (required.length) schema.required = required;
250
+ return schema;
251
+ }
252
+
253
+ /**
254
+ * Bind a contract resource's MCP arguments onto the RequestTarget the way the router would: path params
255
+ * as own properties (`target.id`, `target.n`, …) and declared query params via the URLSearchParams API
256
+ * (so `withSchema`'s pre-dispatch validation/coercion reads them). No-op for non-contract resources, so
257
+ * table tools are unaffected.
258
+ */
259
+ function applyContractInputs(
260
+ target: Record<string, unknown>,
261
+ ResourceClass: ResourceClassLike,
262
+ a: Record<string, unknown>,
263
+ contractVerb: string
264
+ ): void {
265
+ if (!ResourceClass.requestContract) return;
266
+ for (const param of pathParamNames(ResourceClass.path)) {
267
+ if (a[param] !== undefined) target[param] = a[param];
268
+ }
269
+ const queryProps = ResourceClass.inputSchemas?.[contractVerb]?.query?.properties;
270
+ const setter = (target as { set?: (n: string, v: string) => void }).set;
271
+ const appender = (target as { append?: (n: string, v: string) => void }).append;
272
+ if (queryProps && typeof setter === 'function') {
273
+ for (const key of Object.keys(queryProps)) {
274
+ const val = a[key];
275
+ if (val === undefined) continue;
276
+ if (Array.isArray(val) && typeof appender === 'function') {
277
+ for (const el of val) appender.call(target, key, String(el));
278
+ } else {
279
+ setter.call(target, key, String(val));
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ /**
286
+ * MCP delivers path params, query params, and body fields flattened into ONE args object. For a
287
+ * contract resource those non-body keys are already bound onto the target by {@link applyContractInputs};
288
+ * strip them here so the remaining object is the request body the contract's `additionalProperties:false`
289
+ * body schema will accept (otherwise a bound `id`/query key poisons body validation). Non-contract
290
+ * resources pass through unchanged.
291
+ */
292
+ function contractBody(
293
+ a: Record<string, unknown>,
294
+ ResourceClass: ResourceClassLike,
295
+ contractVerb: string
296
+ ): Record<string, unknown> {
297
+ if (!ResourceClass.requestContract) return a;
298
+ const exclude = new Set(pathParamNames(ResourceClass.path));
299
+ const queryProps = ResourceClass.inputSchemas?.[contractVerb]?.query?.properties;
300
+ if (queryProps) for (const key of Object.keys(queryProps)) exclude.add(key);
301
+ if (exclude.size === 0) return a;
302
+ const body: Record<string, unknown> = {};
303
+ for (const [key, val] of Object.entries(a)) if (!exclude.has(key)) body[key] = val;
304
+ return body;
305
+ }
306
+
188
307
  /** A compiled parameterised route (e.g. `/widget/:id`), stored outside the base Map. */
189
308
  interface ParamRouteEntry {
190
309
  pattern: string;
@@ -363,15 +482,40 @@ function wrapResult(data: unknown): ToolResult {
363
482
  }
364
483
 
365
484
  function wrapError(toolName: string, err: unknown): ToolResult {
366
- const e = err as { message?: string; http_resp_msg?: string };
367
- const message = e?.http_resp_msg ?? e?.message ?? `${toolName} failed`;
368
- harperLogger.trace(`MCP ${toolName} threw: ${(err as Error).stack ?? message}`);
485
+ // `err` is arbitrary/untrusted a revoked Proxy or a throwing getter would crash this
486
+ // error-serialization path if we accessed properties bare. Read everything inside one guard.
487
+ let message = `${toolName} failed`;
488
+ let errors: unknown;
489
+ let code: string | undefined;
490
+ let stack: string | undefined;
491
+ try {
492
+ const e = err as { message?: string; http_resp_msg?: string; errors?: unknown; code?: string; stack?: string };
493
+ if (e && typeof e === 'object') {
494
+ message = e.http_resp_msg ?? e.message ?? message;
495
+ errors = e.errors;
496
+ code = e.code;
497
+ stack = e.stack;
498
+ }
499
+ } catch (serializeErr) {
500
+ harperLogger.error(`Failed to read error in wrapError: ${(serializeErr as Error).message}`);
501
+ }
502
+ harperLogger.trace(`MCP ${toolName} threw: ${stack ?? message}`);
503
+ const payload: { kind: string; tool: string; message: string; code?: string; errors?: unknown } = {
504
+ kind: 'harper_error',
505
+ tool: toolName,
506
+ message,
507
+ };
508
+ // Pass structured validation issues through rather than flattening to a single string (RFC 0001 §8).
509
+ if (Array.isArray(errors) && errors.length) {
510
+ payload.code = code;
511
+ payload.errors = errors;
512
+ }
369
513
  return {
370
514
  isError: true,
371
515
  content: [
372
516
  {
373
517
  type: 'text',
374
- text: JSON.stringify({ kind: 'harper_error', tool: toolName, message }),
518
+ text: JSON.stringify(payload),
375
519
  },
376
520
  ],
377
521
  };
@@ -387,6 +531,7 @@ function makeGetHandler(toolName: string, path: string, capturedClass: ResourceC
387
531
  const target = makeTarget();
388
532
  target.id = a.id;
389
533
  if (Array.isArray(a.get_attributes)) target.select = a.get_attributes as string[];
534
+ applyContractInputs(target, ResourceClass, a, 'get');
390
535
  const data = await ResourceClass.get!(target, buildContext(context.user));
391
536
  return wrapResult(data);
392
537
  } catch (err) {
@@ -455,7 +600,12 @@ function makeCreateHandler(toolName: string, path: string, capturedClass: Resour
455
600
  // base `post` throws `missingMethod` ("does not have a post method")
456
601
  // because a record-scoped resource has no insert path (#1317).
457
602
  target.isCollection = true;
458
- const data = await ResourceClass.post!(target, a, buildContext(context.user));
603
+ applyContractInputs(target, ResourceClass, a, 'post');
604
+ const data = await ResourceClass.post!(
605
+ target,
606
+ contractBody(a, ResourceClass, 'post'),
607
+ buildContext(context.user)
608
+ );
459
609
  // Standard table create resolves to the new record's primary key (a
460
610
  // scalar). Wrap it as `{ id }` so the result carries `structuredContent`
461
611
  // matching `deriveCreateOutputSchema`; strict SDK clients reject a bare
@@ -477,13 +627,17 @@ function makeUpdateHandler(toolName: string, path: string, capturedClass: Resour
477
627
  const ResourceClass = liveResource(path, capturedClass);
478
628
  const target = makeTarget();
479
629
  target.id = id;
630
+ applyContractInputs(target, ResourceClass, a, verb);
480
631
  // Call the verb method *on* ResourceClass so `this` stays bound to the
481
632
  // class — detaching it (`const fn = ResourceClass.put`) makes the static
482
633
  // Resource dispatcher read `this.directURLMapping` off undefined and throw.
483
634
  const ctx = buildContext(context.user);
635
+ // For a contract resource, strip path/query keys from the body (they're bound onto the target
636
+ // above); otherwise keep the historical `id`-stripped rest.
637
+ const body = ResourceClass.requestContract ? contractBody(a, ResourceClass, verb) : rest;
484
638
  const data = await (verb === 'put'
485
- ? ResourceClass.put!(target, rest, ctx)
486
- : ResourceClass.patch!(target, rest, ctx));
639
+ ? ResourceClass.put!(target, body, ctx)
640
+ : ResourceClass.patch!(target, body, ctx));
487
641
  // Table.put/patch resolve to undefined; surface a `{ ok: true }`
488
642
  // acknowledgement so the result has structuredContent matching
489
643
  // derive{Update,Patch}OutputSchema. A custom Resource that returns a
@@ -503,6 +657,7 @@ function makeDeleteHandler(toolName: string, path: string, capturedClass: Resour
503
657
  const ResourceClass = liveResource(path, capturedClass);
504
658
  const target = makeTarget();
505
659
  target.id = a.id;
660
+ applyContractInputs(target, ResourceClass, a, 'delete');
506
661
  const data = await ResourceClass.delete!(target, buildContext(context.user));
507
662
  // Table.delete resolves to a boolean; wrap it as `{ deleted }` so the
508
663
  // result carries structuredContent matching deriveDeleteOutputSchema. A
@@ -670,14 +825,26 @@ function registerVerbTools(ctx: ResourceContext): number {
670
825
  const primaryKey = ResourceClass.primaryKey;
671
826
  const ctxForVerb: VerbDescriptionContext = { tableDoc, tableName, primaryKey, path };
672
827
 
673
- const overrideOutput = (verb: Verb): object | undefined => ResourceClass.outputSchemas?.[verb];
828
+ // `outputSchemas` is authored/keyed by MCP verb (`create`/`update`) for table resources, but a request
829
+ // contract stores it by HTTP verb (`post`/`put`); fall back to the HTTP-verb key so a contract's
830
+ // `post.response`/`put.response` reaches the `create_*`/`update_*` output schema.
831
+ const overrideOutput = (verb: Verb): object | undefined =>
832
+ ResourceClass.outputSchemas?.[verb] ?? ResourceClass.outputSchemas?.[CONTRACT_VERB[verb]];
833
+ // When the resource carries a request contract (RFC 0001, Pillar 2), drive the tool INPUT schema off
834
+ // the contract (path params + declared query/body) instead of the generic table-attribute derivation.
835
+ // `search_*` is excluded: the contract has no `search` verb and the `search`/`query` static isn't
836
+ // wrapped by the contract validator, so it keeps the rich generic conditions/sort/limit grammar.
837
+ const overrideInput = (verb: Verb): object | undefined =>
838
+ ResourceClass.requestContract && verb !== 'search'
839
+ ? contractInputSchema(ResourceClass, CONTRACT_VERB[verb])
840
+ : undefined;
674
841
 
675
842
  if (verbs.get) {
676
843
  const name = `get_${suffix}`;
677
844
  addTool({
678
845
  name,
679
846
  description: verbDescription('get', ctxForVerb),
680
- inputSchema: deriveGetSchema(attributes, undefined),
847
+ inputSchema: overrideInput('get') ?? deriveGetSchema(attributes, undefined),
681
848
  outputSchema: overrideOutput('get') ?? deriveGetOutputSchema(attributes, undefined),
682
849
  profile: 'application',
683
850
  annotations: mergeAnnotations('get', { readOnlyHint: true }, ResourceClass),
@@ -693,7 +860,7 @@ function registerVerbTools(ctx: ResourceContext): number {
693
860
  addTool({
694
861
  name,
695
862
  description: verbDescription('search', ctxForVerb),
696
- inputSchema: deriveSearchSchema(attributes, undefined),
863
+ inputSchema: overrideInput('search') ?? deriveSearchSchema(attributes, undefined),
697
864
  profile: 'application',
698
865
  annotations: mergeAnnotations('search', { readOnlyHint: true }, ResourceClass),
699
866
  visibleTo: makeVisibleTo(databaseName, tableName, 'read'),
@@ -706,7 +873,7 @@ function registerVerbTools(ctx: ResourceContext): number {
706
873
  addTool({
707
874
  name,
708
875
  description: verbDescription('create', ctxForVerb),
709
- inputSchema: deriveCreateSchema(attributes, undefined),
876
+ inputSchema: overrideInput('create') ?? deriveCreateSchema(attributes, undefined),
710
877
  outputSchema: overrideOutput('create') ?? deriveCreateOutputSchema(attributes, undefined),
711
878
  profile: 'application',
712
879
  annotations: mergeAnnotations('create', {}, ResourceClass),
@@ -720,7 +887,7 @@ function registerVerbTools(ctx: ResourceContext): number {
720
887
  addTool({
721
888
  name,
722
889
  description: verbDescription('update', ctxForVerb),
723
- inputSchema: deriveUpdateSchema(attributes, undefined),
890
+ inputSchema: overrideInput('update') ?? deriveUpdateSchema(attributes, undefined),
724
891
  outputSchema: overrideOutput('update') ?? deriveUpdateOutputSchema(attributes, undefined),
725
892
  profile: 'application',
726
893
  // PUT semantics: replacing with the same payload yields the same state,
@@ -735,7 +902,7 @@ function registerVerbTools(ctx: ResourceContext): number {
735
902
  addTool({
736
903
  name,
737
904
  description: verbDescription('patch', ctxForVerb),
738
- inputSchema: deriveUpdateSchema(attributes, undefined),
905
+ inputSchema: overrideInput('patch') ?? deriveUpdateSchema(attributes, undefined),
739
906
  outputSchema: overrideOutput('patch') ?? derivePatchOutputSchema(attributes, undefined),
740
907
  profile: 'application',
741
908
  // patch_* idempotency depends on partial-update semantics; default
@@ -755,7 +922,7 @@ function registerVerbTools(ctx: ResourceContext): number {
755
922
  addTool({
756
923
  name,
757
924
  description: verbDescription('delete', ctxForVerb),
758
- inputSchema: deriveDeleteSchema(attributes, undefined),
925
+ inputSchema: overrideInput('delete') ?? deriveDeleteSchema(attributes, undefined),
759
926
  outputSchema: overrideOutput('delete') ?? deriveDeleteOutputSchema(attributes),
760
927
  profile: 'application',
761
928
  // delete_* idempotency depends on delete-of-deleted behavior; default
@@ -1148,6 +1315,9 @@ function buildApplicationTools(resources: ResourcesRegistry): void {
1148
1315
  // segments yet, so ALL generated verbs are dropped. Author-defined
1149
1316
  // mcpTools/mcpPrompts carry their own schemas and handler methods, so they
1150
1317
  // register regardless of binding mode.
1318
+ // A request contract (RFC 0001, Pillar 2) LIFTS this: `applyContractInputs` binds arbitrary
1319
+ // path params + declared query itself, so contract resources register their generated verbs on
1320
+ // any route shape — this is the "richer binding rides on the contract" the plain paths defer.
1151
1321
  const considerEntry = (
1152
1322
  path: string,
1153
1323
  entry: ResourceRegistryEntry | undefined,
@@ -1164,14 +1334,18 @@ function buildApplicationTools(resources: ResourcesRegistry): void {
1164
1334
  return;
1165
1335
  }
1166
1336
  const verbs = detectVerbs(ResourceClass);
1167
- if (paramBinding === 'id') {
1168
- verbs.search = false;
1169
- verbs.create = false;
1170
- } else if (paramBinding === 'none') {
1171
- harperLogger.trace(
1172
- `MCP application: '/${path}' generated verb tools skipped — multi-segment/named-wildcard binding not yet supported`
1173
- );
1174
- verbs.get = verbs.search = verbs.create = verbs.updatePut = verbs.updatePatch = verbs.delete = false;
1337
+ // A request contract binds arbitrary path params + query (applyContractInputs), so it is exempt
1338
+ // from the generated-handler binding restrictions below.
1339
+ if (!ResourceClass.requestContract) {
1340
+ if (paramBinding === 'id') {
1341
+ verbs.search = false;
1342
+ verbs.create = false;
1343
+ } else if (paramBinding === 'none') {
1344
+ harperLogger.trace(
1345
+ `MCP application: '/${path}' generated verb tools skipped — multi-segment/named-wildcard binding not yet supported`
1346
+ );
1347
+ verbs.get = verbs.search = verbs.create = verbs.updatePut = verbs.updatePatch = verbs.delete = false;
1348
+ }
1175
1349
  }
1176
1350
  const hasVerbs = verbs.get || verbs.search || verbs.create || verbs.updatePut || verbs.updatePatch || verbs.delete;
1177
1351
  const hasCustomTools = Array.isArray(ResourceClass?.mcpTools) && ResourceClass.mcpTools.length > 0;
@@ -1207,7 +1381,7 @@ function buildApplicationTools(resources: ResourcesRegistry): void {
1207
1381
  // tools — even though it appears in the OpenAPI document, which already iterates `paramRoutes`.
1208
1382
  // Enumerate them so the tool surface matches the REST surface; the binding mode restricts the
1209
1383
  // GENERATED verb tools to what their handlers actually bind (see considerEntry), while custom
1210
- // mcpTools/mcpPrompts register on every route shape.
1384
+ // mcpTools/mcpPrompts (and contract resources) register on every route shape.
1211
1385
  for (const route of resources.paramRoutes ?? []) {
1212
1386
  considerEntry(route.pattern, route.entry, isSimpleIdRoute(route.pattern) ? 'id' : 'none');
1213
1387
  }
@@ -276,7 +276,7 @@ function buildDescription(operationName: string, hasCuratedSchema: boolean): str
276
276
  * matches the MCP spec's `tools/call` convention so the LLM sees and can
277
277
  * adapt to the failure.
278
278
  */
279
- function makeOperationToolHandler(operationName: string) {
279
+ export function makeOperationToolHandler(operationName: string) {
280
280
  return async function operationToolHandler(args: unknown, context: { user: AuthedUser }): Promise<ToolResult> {
281
281
  const body: Record<string, unknown> = {
282
282
  ...(args && typeof args === 'object' ? (args as Record<string, unknown>) : {}),
@@ -70,12 +70,16 @@ function harperTypeToJsonSchema(type: string | undefined): { type: string | stri
70
70
 
71
71
  function attributeToProperty(attr: HarperAttribute): object {
72
72
  let base: { type?: string | string[]; description?: string; [key: string]: unknown };
73
- if (attr.type === 'Object' && attr.properties) {
73
+ // The GraphQL parser emits nested objects via `.properties` (not a capitalized `'Object'` type) and
74
+ // list types as lowercase `type: 'array'` with `.elements` — the prior `'Object'`/`'Array'` literal
75
+ // checks never matched, so nested shapes fell through to a bare `{ type: 'string' }`. Detect them the
76
+ // way the parser actually emits, matching the shared `attributeToFragment` projector.
77
+ if (attr.properties) {
74
78
  base = {
75
79
  type: 'object',
76
80
  properties: Object.fromEntries(attr.properties.map((p) => [p.name, attributeToProperty(p)])),
77
81
  };
78
- } else if (attr.type === 'Array' && attr.elements) {
82
+ } else if (attr.type === 'array' && attr.elements) {
79
83
  base = {
80
84
  type: 'array',
81
85
  items: attributeToProperty(attr.elements),
@@ -370,15 +370,15 @@ async function deployComponent(req) {
370
370
  throw handleHDBError(validation, validation.message, HTTP_STATUS_CODES.BAD_REQUEST);
371
371
  }
372
372
 
373
- // Ingest any provided registry token into the secrets store so the credential lives as
373
+ // Ingest any provided credential token into the secrets store so the credential lives as
374
374
  // replicated ciphertext (reference, not embed); already-reference entries pass through, and with
375
375
  // no custody a literal token stays as a transient, this-node-only fallback (#1158). Peers
376
376
  // re-running a replicated deploy already carry references and never re-ingest.
377
- const { ingestRegistryAuth, resolveRegistryAuth } = require('./secretOperations.ts');
378
- req.registryAuth = await ingestRegistryAuth(req, req.registryAuth, req.project);
377
+ const { ingestCredentials, resolveCredentials } = require('./secretOperations.ts');
378
+ req.credentials = await ingestCredentials(req, req.credentials, req.project);
379
379
  // References are safe to persist (config + deployment row) and replicate; a no-custody literal
380
380
  // token is not — it is used only for this node's install below, then stripped before replication.
381
- const registryAuthReferences = (req.registryAuth ?? []).filter((entry) => entry && entry.secret !== undefined);
381
+ const credentialReferences = (req.credentials ?? []).filter((entry) => entry && entry.secret !== undefined);
382
382
 
383
383
  // Write to root config if the request contains a package identifier
384
384
  if (req.package) {
@@ -403,9 +403,9 @@ async function deployComponent(req) {
403
403
  };
404
404
  }
405
405
  if (req.urlPath !== undefined) applicationConfig.urlPath = req.urlPath;
406
- // Persist registry-auth references (never tokens) so every cold install of this component —
406
+ // Persist credential references (never tokens) so every cold install of this component —
407
407
  // reboot, new peer, rollback — re-resolves the credential from the store.
408
- if (registryAuthReferences.length) applicationConfig.registryAuth = registryAuthReferences;
408
+ if (credentialReferences.length) applicationConfig.credentials = credentialReferences;
409
409
  await configUtils.addConfig(req.project, applicationConfig);
410
410
  }
411
411
 
@@ -430,8 +430,8 @@ async function deployComponent(req) {
430
430
  package_identifier: req.package ?? null,
431
431
  user: req.hdb_user?.username,
432
432
  restart_mode: req.restart === 'rolling' ? 'rolling' : req.restart ? 'immediate' : null,
433
- // Reference form only — the rollback source for re-resolving registry auth.
434
- registry_auth: registryAuthReferences.length ? registryAuthReferences : null,
433
+ // Reference form only — the rollback source for re-resolving the credential.
434
+ credentials: credentialReferences.length ? credentialReferences : null,
435
435
  emitter,
436
436
  });
437
437
  if (recorder) req._deploymentId = recorder.deploymentId;
@@ -469,17 +469,17 @@ async function deployComponent(req) {
469
469
  extractionPayload = row.payload_blob.stream();
470
470
  }
471
471
 
472
- // Resolve registryAuth references into concrete tokens for this node's npm pack/install
472
+ // Resolve credential references into concrete tokens for this node's npm pack/install
473
473
  // (a no-custody literal-token fallback passes through unchanged). On a peer running a
474
474
  // replicated deploy, the referenced hdb_secret row may arrive just behind the deploy op, so
475
475
  // allow a bounded grace period (same budget as the payload-row wait) for it to replicate in.
476
- let registryAuthWaitMs = 0;
476
+ let credentialsWaitMs = 0;
477
477
  if (isReplicatedExecution) {
478
478
  const requested = Number(req.deployment_timeout);
479
- registryAuthWaitMs = Number.isFinite(requested) && requested >= 0 ? requested : DEFAULT_AWAIT_ROW_TIMEOUT_MS;
479
+ credentialsWaitMs = Number.isFinite(requested) && requested >= 0 ? requested : DEFAULT_AWAIT_ROW_TIMEOUT_MS;
480
480
  }
481
- const resolvedRegistryAuth = await resolveRegistryAuth(req.registryAuth, req.project, {
482
- waitMs: registryAuthWaitMs,
481
+ const resolvedCredentials = await resolveCredentials(req.credentials, req.project, {
482
+ waitMs: credentialsWaitMs,
483
483
  });
484
484
 
485
485
  const application = new Application({
@@ -498,16 +498,18 @@ async function deployComponent(req) {
498
498
  installCapture.push(manager, stream, line);
499
499
  if (emitter) emit('install', { manager, stream, line });
500
500
  },
501
- // Private-registry auth (already resolved above), used here for this node's npm pack/install.
502
- registryAuth: resolvedRegistryAuth,
501
+ // Deploy credentials (already resolved above), used here for this node's npm pack/install:
502
+ // registry entries via a transient .npmrc, git-host entries via the in-memory credential
503
+ // socket the clone spawn talks to.
504
+ credentials: resolvedCredentials,
503
505
  });
504
- // Reduce req.registryAuth to references only (never a token) before it can reach an error/log
506
+ // Reduce req.credentials to references only (never a token) before it can reach an error/log
505
507
  // path or replication: references are what peers resolve from their own replicated hdb_secret
506
508
  // copy; a no-custody literal token is dropped entirely (peers fall back to their fabric-injected
507
509
  // NPM_CONFIG_USERCONFIG, as before). This also fixes the prior success-only strip that leaked a
508
510
  // literal token on a prepare/load failure.
509
- if (registryAuthReferences.length) req.registryAuth = registryAuthReferences;
510
- else delete req.registryAuth;
511
+ if (credentialReferences.length) req.credentials = credentialReferences;
512
+ else delete req.credentials;
511
513
 
512
514
  emit('phase', { phase: 'prepare', status: 'start' });
513
515
  await prepareApplication(application);
@@ -556,7 +558,7 @@ async function deployComponent(req) {
556
558
  // ProgressEmitter holds function listeners that can't survive the replication
557
559
  // channel's serialization; strip it unconditionally.
558
560
  delete req.progress;
559
- // req.registryAuth was already deleted immediately after the Application ctor (above) so the
561
+ // req.credentials was already deleted immediately after the Application ctor (above) so the
560
562
  // token never reaches the replication channel or a peer's operation log; peers authenticate
561
563
  // against the private registry via their own fabric-injected NPM_CONFIG_USERCONFIG on reinstall.
562
564
  if (systemReplicated && recorder) {
@@ -371,6 +371,69 @@ function packageComponentValidator(req) {
371
371
  return validator.validateBySchema(req, packageProjSchema);
372
372
  }
373
373
 
374
+ // An npm registry-auth credential entry, identified by its `registry` key. `host` is forbidden
375
+ // rather than merely unused: operation validation allows unknown keys, so without this an entry
376
+ // carrying both discriminators would validate as npm registry auth and its git half would be
377
+ // silently dropped.
378
+ const REGISTRY_CREDENTIAL_ENTRY = Joi.object({
379
+ host: Joi.any().forbidden().messages({
380
+ 'any.unknown': `a credential entry is either npm registry auth ('registry') or git host auth ('host'), not both`,
381
+ }),
382
+ // registry and token are written verbatim into the transient .npmrc, which is line-based;
383
+ // forbid CR/LF so a super_user can't inject extra npm config lines. (registry also accepts
384
+ // bare hosts and //host/ forms, so a strict URI validator would reject supported inputs — the
385
+ // newline guard is the right scope here.)
386
+ registry: Joi.string()
387
+ .pattern(/^[^\r\n]+$/)
388
+ .required(),
389
+ token: Joi.string().pattern(/^[^\r\n]+$/),
390
+ // A reference into the hdb_secret store; same name grammar as set_secret's `name`.
391
+ secret: Joi.string()
392
+ .pattern(ENV_KEY_REGEX)
393
+ .messages({ 'string.pattern.base': `'secret' must only contain word characters, dots and dashes` }),
394
+ scope: Joi.string()
395
+ .pattern(/^@[a-z0-9-_.]+$/)
396
+ .optional(),
397
+ })
398
+ .xor('token', 'secret')
399
+ // The whole operation validates with allowUnknown, but a credential entry must not: an unknown
400
+ // key here (a typo'd or future secret-bearing field like `password`) would pass through ingest
401
+ // unchanged and be persisted to config/hdb_deployment and replicated, defeating reference-only.
402
+ .unknown(false);
403
+
404
+ // A git-host credential entry, identified by its `host` key (#1792). Used to authenticate the
405
+ // `git clone`/`git ls-remote` npm runs for a git-reference `package` (e.g. `github:org/repo`); the
406
+ // token is served to git from memory, never written to a file or a URL.
407
+ const GIT_CREDENTIAL_ENTRY = Joi.object({
408
+ // A bare host, optionally with a port — `github.com`, `git.example.com:8443`. A scheme or path is
409
+ // tolerated and normalized away, but a credential is matched by host, so keep the grammar tight.
410
+ host: Joi.string()
411
+ .pattern(/^[^\s/@\\]+$/)
412
+ .required()
413
+ .messages({ 'string.pattern.base': `'host' must be a bare git host, e.g. 'github.com'` }),
414
+ // The username half of git's HTTPS basic auth. Defaults to GitHub's `x-access-token` convention;
415
+ // GitLab wants `oauth2` and Bitbucket `x-token-auth`.
416
+ username: Joi.string()
417
+ .pattern(/^[^\r\n:]+$/)
418
+ .optional(),
419
+ // Capped like other secret-bearing fields (SECRET_MAX_LENGTH, above): an unbounded literal token
420
+ // here feeds straight into synchronous envelope-sealing crypto, so without a cap it's a
421
+ // resource-exhaustion vector, not just a storage one.
422
+ token: Joi.string()
423
+ .pattern(/^[^\r\n]+$/)
424
+ .max(SECRET_MAX_LENGTH),
425
+ secret: Joi.string()
426
+ .pattern(ENV_KEY_REGEX)
427
+ .messages({ 'string.pattern.base': `'secret' must only contain word characters, dots and dashes` }),
428
+ // `registry` forbidden for symmetry with the npm entry: an entry carrying both discriminators has
429
+ // no single kind and must be rejected, not silently treated as git auth.
430
+ registry: Joi.any().forbidden().messages({
431
+ 'any.unknown': `a credential entry is either npm registry auth ('registry') or git host auth ('host'), not both`,
432
+ }),
433
+ })
434
+ .xor('token', 'secret')
435
+ .unknown(false);
436
+
374
437
  /**
375
438
  * Validate deployComponent requests.
376
439
  * @param req
@@ -398,33 +461,35 @@ function deployComponentValidator(req) {
398
461
  })
399
462
  .optional()
400
463
  .messages({ 'any.invalid': 'urlPath must not contain ".."' }),
401
- // Private-registry auth. Each entry supplies its credential exactly one of two ways:
402
- // - `token`: a literal token, used only for this node's npm pack/install and never
403
- // persisted or replicated (stripped from req before replicateOperation).
464
+ // Deploy credentials. The array is kind-heterogeneous: an entry's kind is implied by its
465
+ // identifying key rather than a separate discriminator field, so a new kind is added as
466
+ // another item alternative here without reshaping the field. Today: npm registry auth
467
+ // (`registry`) and git host auth for a git-reference package (`host`, #1792).
468
+ //
469
+ // Every kind supplies its credential exactly one of two ways:
470
+ // - `token`: a literal token, used only for this node's install and never persisted or
471
+ // replicated (stripped from req before replicateOperation).
404
472
  // - `secret`: the name of an hdb_secret row (#1550); the token is resolved by decrypting
405
473
  // that row on this node at deploy time, so the credential lives in the secrets store
406
474
  // (reference, not embed) instead of travelling in the operation body.
407
- registryAuth: Joi.array()
475
+ // Dispatched on the presence of `registry` rather than tried as alternatives, so a malformed
476
+ // entry reports what is actually wrong with it (a newline in the token, an invalid scope) rather
477
+ // than a generic "no alternative matched".
478
+ credentials: Joi.array()
408
479
  .items(
409
- Joi.object({
410
- // registry and token are written verbatim into the transient .npmrc, which is
411
- // line-based; forbid CR/LF so a super_user can't inject extra npm config lines.
412
- // (registry also accepts bare hosts and //host/ forms, so a strict URI validator
413
- // would reject supported inputs — the newline guard is the right scope here.)
414
- registry: Joi.string()
415
- .pattern(/^[^\r\n]+$/)
416
- .required(),
417
- token: Joi.string().pattern(/^[^\r\n]+$/),
418
- // A reference into the hdb_secret store; same name grammar as set_secret's `name`.
419
- secret: Joi.string()
420
- .pattern(ENV_KEY_REGEX)
421
- .messages({ 'string.pattern.base': `'secret' must only contain word characters, dots and dashes` }),
422
- scope: Joi.string()
423
- .pattern(/^@[a-z0-9-_.]+$/)
424
- .optional(),
425
- }).xor('token', 'secret')
480
+ Joi.alternatives().conditional('.registry', {
481
+ is: Joi.exist(),
482
+ then: REGISTRY_CREDENTIAL_ENTRY,
483
+ otherwise: GIT_CREDENTIAL_ENTRY,
484
+ })
426
485
  )
427
486
  .optional(),
487
+ // `registryAuth` was this field's name on the 5.2 dev line before it grew to carry other
488
+ // credential kinds. Rejected rather than ignored: validation allows unknown keys, so a caller
489
+ // still sending it would otherwise get a deploy that silently installs with no credentials.
490
+ registryAuth: Joi.any().forbidden().messages({
491
+ 'any.unknown': `'registryAuth' has been renamed to 'credentials'`,
492
+ }),
428
493
  }).with('urlPath', 'package');
429
494
 
430
495
  return validator.validateBySchema(req, deployProjSchema);