From 8c5d585b7f73dc298be00b3a0353731f392f90e6 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Thu, 26 Mar 2026 00:25:52 +0300 Subject: [PATCH 1/2] feat: expose request header configuration --- TASKS.md | 12 +++--- .../src/features/operation-form/model.test.ts | 33 ++++++++++++++- apps/ui/src/features/operation-form/model.ts | 41 +++++++++++++++++-- .../operation-form/operation-form.tsx | 14 +++++++ apps/ui/src/pages/operation-detail/page.tsx | 29 +++++++++++++ 5 files changed, 119 insertions(+), 10 deletions(-) diff --git a/TASKS.md b/TASKS.md index bdf4da4..e699058 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,17 +2,17 @@ ## Current -### `feat/ui-ux-pass` +### `feat/header-fields` Status: completed DoD: -- shell выглядит как цельный продуктовый интерфейс, а не как набор отдельных секций -- operation list показывает обзор состояния системы, а не только список карточек -- create page объясняет оператору различия между `REST`, `GraphQL` и `gRPC` -- ключевые сценарии имеют пустые состояния и более понятную визуальную иерархию -- UI build и tests остаются зелеными после UX-pass +- форма создания явно поддерживает настройку headers, а не прячет их только в сыром JSON +- `execution headers` доступны для всех протоколов +- `static headers` доступны для REST target +- detail view показывает сохраненные headers +- UI build и tests остаются зелеными ## Next diff --git a/apps/ui/src/features/operation-form/model.test.ts b/apps/ui/src/features/operation-form/model.test.ts index dea833c..924c4f9 100644 --- a/apps/ui/src/features/operation-form/model.test.ts +++ b/apps/ui/src/features/operation-form/model.test.ts @@ -4,11 +4,33 @@ import { buildOperationPayload, defaultOperationFormValues } from "./model"; describe("buildOperationPayload", () => { it("creates a REST operation payload from form values", () => { - const payload = buildOperationPayload(defaultOperationFormValues); + const payload = buildOperationPayload({ + ...defaultOperationFormValues, + restStaticHeadersText: JSON.stringify( + { + "x-app-source": "rmcp", + }, + null, + 2, + ), + executionHeadersText: JSON.stringify( + { + "x-trace-id": "trace-123", + }, + null, + 2, + ), + }); expect(payload.protocol).toBe("rest"); expect(payload.target.kind).toBe("rest"); expect(payload.target.method).toBe("POST"); + expect(payload.target.static_headers).toEqual({ + "x-app-source": "rmcp", + }); + expect(payload.execution_config.headers).toEqual({ + "x-trace-id": "trace-123", + }); expect(payload.input_mapping).toEqual({ rules: [ { @@ -111,4 +133,13 @@ describe("buildOperationPayload", () => { }), ).toThrow(/Input schema contains invalid JSON/); }); + + it("throws when header values are not string maps", () => { + expect(() => + buildOperationPayload({ + ...defaultOperationFormValues, + executionHeadersText: JSON.stringify({ "x-trace-id": 42 }, null, 2), + }), + ).toThrow(/Execution headers.x-trace-id must be a string value/); + }); }); diff --git a/apps/ui/src/features/operation-form/model.ts b/apps/ui/src/features/operation-form/model.ts index 4a0d291..b91bda0 100644 --- a/apps/ui/src/features/operation-form/model.ts +++ b/apps/ui/src/features/operation-form/model.ts @@ -17,6 +17,7 @@ export const operationFormSchema = z restBaseUrl: z.string(), restMethod: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), restPathTemplate: z.string(), + restStaticHeadersText: jsonTextSchema, graphqlEndpoint: z.string(), graphqlOperationType: z.enum(["query", "mutation"]), graphqlOperationName: z.string(), @@ -32,6 +33,7 @@ export const operationFormSchema = z outputSchemaText: jsonTextSchema, inputMappingText: jsonTextSchema, outputMappingText: jsonTextSchema, + executionHeadersText: jsonTextSchema, executionConfigText: jsonTextSchema, }) .superRefine((values, context) => { @@ -111,6 +113,22 @@ export const operationFormSchema = z export type OperationFormValues = z.infer; +function parseStringMap(raw: string, fieldName: string) { + const value = safeParseJson>(raw, fieldName); + + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw new Error(`${fieldName} must be a JSON object`); + } + + for (const [key, entry] of Object.entries(value)) { + if (typeof entry !== "string") { + throw new Error(`${fieldName}.${key} must be a string value`); + } + } + + return value as Record; +} + function defaultInputSchemaText() { return JSON.stringify( { @@ -265,6 +283,7 @@ export const defaultOperationFormValues: OperationFormValues = { restBaseUrl: "https://api.example.com", restMethod: "POST", restPathTemplate: "/v1/leads", + restStaticHeadersText: JSON.stringify({}, null, 2), graphqlEndpoint: "https://api.example.com/graphql", graphqlOperationType: "mutation", graphqlOperationName: "CreateLead", @@ -285,6 +304,7 @@ export const defaultOperationFormValues: OperationFormValues = { outputSchemaText: defaultOutputSchemaText(), inputMappingText: defaultRestInputMappingText(), outputMappingText: defaultRestOutputMappingText(), + executionHeadersText: JSON.stringify({}, null, 2), executionConfigText: defaultExecutionConfigText(), }; @@ -300,6 +320,8 @@ export function getProtocolPreset( toolDescription: "Creates a CRM lead from MCP input fields.", inputMappingText: defaultRestInputMappingText(), outputMappingText: defaultRestOutputMappingText(), + restStaticHeadersText: JSON.stringify({}, null, 2), + executionHeadersText: JSON.stringify({}, null, 2), }; case "graphql": return { @@ -310,6 +332,7 @@ export function getProtocolPreset( "Executes a fixed GraphQL mutation and returns the selected lead payload.", inputMappingText: defaultGraphqlInputMappingText(), outputMappingText: defaultGraphqlOutputMappingText(), + executionHeadersText: JSON.stringify({}, null, 2), }; case "grpc": return { @@ -320,6 +343,7 @@ export function getProtocolPreset( "Executes a unary gRPC method using a descriptor-driven request contract.", inputMappingText: defaultGrpcInputMappingText(), outputMappingText: defaultGrpcOutputMappingText(), + executionHeadersText: JSON.stringify({}, null, 2), }; } } @@ -330,10 +354,18 @@ export function buildOperationPayload(rawValues: OperationFormValues) { const outputSchema = safeParseJson(values.outputSchemaText, "Output schema"); const inputMapping = safeParseJson(values.inputMappingText, "Input mapping"); const outputMapping = safeParseJson(values.outputMappingText, "Output mapping"); - const executionConfig = safeParseJson( + const executionHeaders = parseStringMap( + values.executionHeadersText, + "Execution headers", + ); + const executionConfig = safeParseJson>( values.executionConfigText, "Execution config", ); + const normalizedExecutionConfig = { + ...executionConfig, + headers: executionHeaders, + }; const basePayload = { name: values.name, @@ -342,7 +374,7 @@ export function buildOperationPayload(rawValues: OperationFormValues) { output_schema: outputSchema, input_mapping: inputMapping, output_mapping: outputMapping, - execution_config: executionConfig, + execution_config: normalizedExecutionConfig, tool_description: { title: values.toolTitle, description: values.toolDescription, @@ -361,7 +393,10 @@ export function buildOperationPayload(rawValues: OperationFormValues) { base_url: values.restBaseUrl, method: values.restMethod, path_template: values.restPathTemplate, - static_headers: {}, + static_headers: parseStringMap( + values.restStaticHeadersText, + "REST static headers", + ), }, }; case "graphql": diff --git a/apps/ui/src/features/operation-form/operation-form.tsx b/apps/ui/src/features/operation-form/operation-form.tsx index 947acd4..d654928 100644 --- a/apps/ui/src/features/operation-form/operation-form.tsx +++ b/apps/ui/src/features/operation-form/operation-form.tsx @@ -41,6 +41,13 @@ const restFields: FieldConfig[] = [ { name: "restBaseUrl", label: "Base URL" }, { name: "restMethod", label: "HTTP method" }, { name: "restPathTemplate", label: "Path template" }, + { + name: "restStaticHeadersText", + label: "Static headers", + description: "Always sent for this REST target before dynamic request headers are merged.", + rows: 8, + wide: true, + }, ]; const graphqlFields: FieldConfig[] = [ @@ -89,6 +96,13 @@ const schemaFields: FieldConfig[] = [ const mappingFields: FieldConfig[] = [ { name: "inputMappingText", label: "Input mapping", rows: 12, wide: true }, { name: "outputMappingText", label: "Output mapping", rows: 12, wide: true }, + { + name: "executionHeadersText", + label: "Execution headers", + description: "Shared transport headers available for REST, GraphQL and gRPC requests.", + rows: 8, + wide: true, + }, { name: "executionConfigText", label: "Execution config", diff --git a/apps/ui/src/pages/operation-detail/page.tsx b/apps/ui/src/pages/operation-detail/page.tsx index fffc5da..9a682bd 100644 --- a/apps/ui/src/pages/operation-detail/page.tsx +++ b/apps/ui/src/pages/operation-detail/page.tsx @@ -40,6 +40,25 @@ function describeTarget(target: OperationTarget) { } } +function readObjectRecord(value: unknown) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + return value as Record; +} + +function readExecutionHeaders(value: unknown) { + const config = readObjectRecord(value); + const headers = config.headers; + + if (headers === null || typeof headers !== "object" || Array.isArray(headers)) { + return {}; + } + + return headers as Record; +} + function draftSubtitle(target: OperationTarget) { switch (target.kind) { case "rest": @@ -147,6 +166,16 @@ export function OperationDetailPage() {

Execution config

{JSON.stringify(snapshot.execution_config, null, 2)}
+
+

Transport headers

+
{JSON.stringify(readExecutionHeaders(snapshot.execution_config), null, 2)}
+
+ {snapshot.target.kind === "rest" ? ( +
+

REST static headers

+
{JSON.stringify(snapshot.target.static_headers ?? {}, null, 2)}
+
+ ) : null} ) : null} From 9d1f5347c2bc290885f77034d8c215b416cbba1a Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Thu, 26 Mar 2026 01:18:53 +0300 Subject: [PATCH 2/2] feat: align operator UI with design concept --- TASKS.md | 12 +- .../operation-form/operation-form.tsx | 422 ++++-- apps/ui/src/pages/operation-create/page.tsx | 46 +- apps/ui/src/shared/ui/app-shell.tsx | 147 +- apps/ui/src/styles.css | 1244 +++++++++++------ 5 files changed, 1201 insertions(+), 670 deletions(-) diff --git a/TASKS.md b/TASKS.md index e699058..f56d12d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,17 +2,17 @@ ## Current -### `feat/header-fields` +### `feat/ui-concept-alignment` Status: completed DoD: -- форма создания явно поддерживает настройку headers, а не прячет их только в сыром JSON -- `execution headers` доступны для всех протоколов -- `static headers` доступны для REST target -- detail view показывает сохраненные headers -- UI build и tests остаются зелеными +- shell и create flow визуально опираются на утвержденный concept +- sidebar, topbar и page header выглядят как единая продуктовая система +- create page больше не выглядит как длинная сырая форма, а как step-based contract builder +- кодовые JSON и mapping поля имеют отдельную визуальную подачу +- UI build и tests остаются зелеными после выравнивания под concept ## Next diff --git a/apps/ui/src/features/operation-form/operation-form.tsx b/apps/ui/src/features/operation-form/operation-form.tsx index d654928..3ef4902 100644 --- a/apps/ui/src/features/operation-form/operation-form.tsx +++ b/apps/ui/src/features/operation-form/operation-form.tsx @@ -22,103 +22,155 @@ type FieldConfig = { description?: string; wide?: boolean; rows?: number; + code?: boolean; }; +const protocolCards = [ + { + protocol: "rest", + title: "REST / HTTP", + description: + "One HTTP method and one path template, with body, query and header mapping.", + }, + { + protocol: "graphql", + title: "GraphQL", + description: + "One fixed query or mutation with stable variables and a typed response shape.", + }, + { + protocol: "grpc", + title: "gRPC (unary)", + description: + "One unary method backed by a descriptor-set contract and typed request payload.", + }, +] as const satisfies Array<{ + protocol: OperationFormValues["protocol"]; + title: string; + description: string; +}>; + const commonFields: FieldConfig[] = [ { name: "name", label: "Tool name" }, { name: "displayName", label: "Display name" }, { name: "toolTitle", label: "Tool title" }, { name: "toolDescription", - label: "Tool description", - description: "LLM-facing description for the MCP tool.", + label: "Description", + description: "LLM-facing description for the tool runtime contract.", rows: 4, wide: true, }, ]; const restFields: FieldConfig[] = [ - { name: "restBaseUrl", label: "Base URL" }, - { name: "restMethod", label: "HTTP method" }, + { name: "restBaseUrl", label: "Base URL", wide: true }, { name: "restPathTemplate", label: "Path template" }, - { - name: "restStaticHeadersText", - label: "Static headers", - description: "Always sent for this REST target before dynamic request headers are merged.", - rows: 8, - wide: true, - }, + { name: "restMethod", label: "HTTP method" }, ]; const graphqlFields: FieldConfig[] = [ - { name: "graphqlEndpoint", label: "GraphQL endpoint" }, + { name: "graphqlEndpoint", label: "GraphQL endpoint", wide: true }, { name: "graphqlOperationType", label: "Operation type" }, { name: "graphqlOperationName", label: "Operation name" }, { - name: "graphqlQueryTemplate", - label: "Query template", - description: "A fixed query or mutation document exposed as a single MCP tool.", - rows: 10, + name: "graphqlResponsePath", + label: "Response path", + description: "Stable extraction root inside the response payload.", wide: true, }, { - name: "graphqlResponsePath", - label: "Response path", - description: "JSONPath-like extraction root inside response preview.", + name: "graphqlQueryTemplate", + label: "Query template", + description: "Fixed GraphQL document exposed as one MCP tool.", + rows: 12, wide: true, + code: true, }, ]; const grpcFields: FieldConfig[] = [ - { name: "grpcServerAddr", label: "Server address" }, + { name: "grpcServerAddr", label: "Server address", wide: true }, { name: "grpcPackage", label: "Package" }, { name: "grpcService", label: "Service" }, { name: "grpcMethod", label: "Method" }, { name: "grpcDescriptorRef", label: "Descriptor reference", - description: "Stable descriptor identifier stored together with the operation.", + description: "Stable descriptor identifier stored with the operation.", + wide: true, }, { name: "grpcDescriptorSetB64", label: "Descriptor set base64", - description: "Use a descriptor-set file to prefill this field before creation.", - rows: 8, + description: "Compiled descriptor-set contents used for runtime invocation.", + rows: 10, wide: true, + code: true, }, ]; const schemaFields: FieldConfig[] = [ - { name: "inputSchemaText", label: "Input schema", rows: 12, wide: true }, - { name: "outputSchemaText", label: "Output schema", rows: 12, wide: true }, + { + name: "inputSchemaText", + label: "Input schema", + rows: 14, + wide: true, + code: true, + }, + { + name: "outputSchemaText", + label: "Output schema", + rows: 14, + wide: true, + code: true, + }, ]; const mappingFields: FieldConfig[] = [ - { name: "inputMappingText", label: "Input mapping", rows: 12, wide: true }, - { name: "outputMappingText", label: "Output mapping", rows: 12, wide: true }, + { + name: "inputMappingText", + label: "Input → Request mapping", + rows: 12, + wide: true, + code: true, + }, + { + name: "outputMappingText", + label: "Response → Output mapping", + rows: 12, + wide: true, + code: true, + }, +]; + +const headerFields: FieldConfig[] = [ { name: "executionHeadersText", label: "Execution headers", - description: "Shared transport headers available for REST, GraphQL and gRPC requests.", + description: "Shared transport headers for REST, GraphQL and gRPC requests.", rows: 8, wide: true, + code: true, }, { name: "executionConfigText", label: "Execution config", + description: "Timeouts, auth profile reference and protocol options.", rows: 10, wide: true, + code: true, }, ]; function protocolSummary(protocol: OperationFormValues["protocol"]) { switch (protocol) { case "rest": - return "One MCP tool maps to one HTTP method and one path template."; + return "One tool maps to one HTTP method and one path template."; case "graphql": - return "One MCP tool maps to one fixed GraphQL query or mutation with stable variables and response shape."; + return "One tool maps to one fixed GraphQL query or mutation."; case "grpc": - return "One MCP tool maps to one unary gRPC method backed by a descriptor set embedded at creation time."; + return "One tool maps to one unary gRPC method backed by a descriptor set."; } } @@ -202,53 +254,13 @@ export function OperationForm() { } } - function renderField(field: FieldConfig) { - const fieldError = form.formState.errors[field.name]; - const message = - typeof fieldError?.message === "string" ? fieldError.message : undefined; + function fieldError(name: OperationFieldName) { + const error = form.formState.errors[name]; + return typeof error?.message === "string" ? error.message : undefined; + } - if ( - field.name === "restMethod" || - field.name === "graphqlOperationType" || - field.name === "protocol" - ) { - return ( - - ); - } - - const isTextarea = field.rows !== undefined; + function renderSelect(field: FieldConfig) { + const message = fieldError(field.name); return (