From c1447c83658604aabc4813083af9c351f30c0d1f Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Wed, 25 Mar 2026 21:52:53 +0300 Subject: [PATCH] feat: polish protocol-aware operator UI --- TASKS.md | 20 +- apps/ui/src/entities/operation/api.ts | 46 ++- apps/ui/src/entities/operation/types.ts | 50 ++- .../ui/src/features/grpc-descriptor/panel.tsx | 160 ++++++++ .../src/features/operation-form/model.test.ts | 89 ++++- apps/ui/src/features/operation-form/model.ts | 371 +++++++++++++++--- .../operation-form/operation-form.tsx | 349 ++++++++++++---- apps/ui/src/pages/operation-create/page.tsx | 2 +- apps/ui/src/pages/operation-detail/page.tsx | 271 ++++++++----- apps/ui/src/shared/api/client.ts | 12 + apps/ui/src/shared/ui/app-shell.tsx | 6 +- apps/ui/src/styles.css | 125 +++++- 12 files changed, 1246 insertions(+), 255 deletions(-) create mode 100644 apps/ui/src/features/grpc-descriptor/panel.tsx diff --git a/TASKS.md b/TASKS.md index 8d46417..45f97a5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,26 +2,22 @@ ## Current -### `feat/deployment-cd-polish` +### `feat/ui-polish` Status: completed DoD: -- CI проверяет deployment artifacts -- CD запускается только после успешного CI на `main` или вручную -- compose не публикует внутренние сервисы шире, чем нужно -- deploy workflow проверяет health сервисов после запуска -- deployment documentation синхронизирована с фактическим pipeline +- UI больше не выглядит `REST-only`, если backend уже поддерживает `REST`, `GraphQL` и `gRPC` +- create flow позволяет собрать operation для всех поддерживаемых протоколов +- detail screen показывает protocol-specific metadata и не сводится к одному длинному сырому столбцу +- для `gRPC` доступен descriptor upload и просмотр unary services/methods +- UI build и UI tests проходят в CI-эквивалентном режиме ## Next -- `feat/ui-polish` +- `feat/demo-assets` ## Backlog -- `feat/admin-api-v1` -- `feat/ui-v1` -- `feat/mcp-server` -- `feat/graphql-support` -- `feat/grpc-support` +- `feat/demo-assets` diff --git a/apps/ui/src/entities/operation/api.ts b/apps/ui/src/entities/operation/api.ts index 83e0a04..1680a93 100644 --- a/apps/ui/src/entities/operation/api.ts +++ b/apps/ui/src/entities/operation/api.ts @@ -1,7 +1,14 @@ -import { getJson, getText, postJson, postText } from "../../shared/api/client"; +import { + getJson, + getText, + postBytes, + postJson, + postText, +} from "../../shared/api/client"; import type { AuthProfile, DraftGenerationResult, + GrpcServiceSummary, OperationRecord, OperationSummary, TestRunResult, @@ -82,3 +89,40 @@ export function importOperationYaml(yamlText: string, mode: "create" | "upsert") export function listAuthProfiles() { return getJson<{ items: AuthProfile[] }>("/api/admin/auth-profiles"); } + +export function uploadProtoDescriptor( + operationId: string, + fileName: string, + bytes: ArrayBuffer, +) { + return postBytes<{ descriptor_id: string; version: number }>( + `/api/admin/operations/${operationId}/descriptors/proto`, + bytes, + { + "Content-Type": "application/octet-stream", + "x-file-name": fileName, + }, + ); +} + +export function uploadDescriptorSet( + operationId: string, + fileName: string, + bytes: ArrayBuffer, +) { + return postBytes<{ descriptor_id: string; version: number }>( + `/api/admin/operations/${operationId}/descriptors/descriptor-set`, + bytes, + { + "Content-Type": "application/octet-stream", + "x-file-name": fileName, + }, + ); +} + +export function listGrpcServices(operationId: string, version?: number) { + const query = version === undefined ? "" : `?version=${version}`; + return getJson<{ services: GrpcServiceSummary[] }>( + `/api/admin/operations/${operationId}/grpc/services${query}`, + ); +} diff --git a/apps/ui/src/entities/operation/types.ts b/apps/ui/src/entities/operation/types.ts index 33c1724..6a96c7f 100644 --- a/apps/ui/src/entities/operation/types.ts +++ b/apps/ui/src/entities/operation/types.ts @@ -1,6 +1,35 @@ export type Protocol = "rest" | "graphql" | "grpc"; export type OperationStatus = "draft" | "testing" | "published" | "archived"; +export type RestTarget = { + kind: "rest"; + base_url: string; + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + path_template: string; + static_headers?: Record; +}; + +export type GraphqlTarget = { + kind: "graphql"; + endpoint: string; + operation_type: "query" | "mutation"; + operation_name: string; + query_template: string; + response_path: string; +}; + +export type GrpcTarget = { + kind: "grpc"; + server_addr: string; + package: string; + service: string; + method: string; + descriptor_ref: string; + descriptor_set_b64: string; +}; + +export type OperationTarget = RestTarget | GraphqlTarget | GrpcTarget; + export type OperationSummary = { id: string; name: string; @@ -31,13 +60,7 @@ export type OperationSnapshot = { protocol: Protocol; status: OperationStatus; version: number; - target: { - kind: "rest" | "graphql" | "grpc"; - base_url?: string; - method?: string; - path_template?: string; - static_headers?: Record; - }; + target: OperationTarget; input_schema: unknown; output_schema: unknown; input_mapping: unknown; @@ -83,3 +106,16 @@ export type AuthProfile = { created_at: string; updated_at: string; }; + +export type GrpcMethodSummary = { + name: string; + kind: string; + input_schema: unknown; + output_schema: unknown; +}; + +export type GrpcServiceSummary = { + package: string; + service: string; + methods: GrpcMethodSummary[]; +}; diff --git a/apps/ui/src/features/grpc-descriptor/panel.tsx b/apps/ui/src/features/grpc-descriptor/panel.tsx new file mode 100644 index 0000000..63cef2b --- /dev/null +++ b/apps/ui/src/features/grpc-descriptor/panel.tsx @@ -0,0 +1,160 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +import { + listGrpcServices, + uploadDescriptorSet, + uploadProtoDescriptor, +} from "../../entities/operation/api"; +import type { GrpcServiceSummary } from "../../entities/operation/types"; +import { ApiError } from "../../shared/api/client"; + +type GrpcDescriptorPanelProps = { + operationId: string; + version: number; +}; + +type UploadKind = "proto" | "descriptor-set"; + +function descriptorSubtitle(service: GrpcServiceSummary) { + return service.package.length > 0 + ? `${service.package}.${service.service}` + : service.service; +} + +export function GrpcDescriptorPanel({ + operationId, + version, +}: GrpcDescriptorPanelProps) { + const queryClient = useQueryClient(); + const [uploadError, setUploadError] = useState(null); + + const servicesQuery = useQuery({ + queryKey: ["grpc-services", operationId, version], + queryFn: () => listGrpcServices(operationId, version), + enabled: operationId.length > 0 && version > 0, + }); + + const uploadMutation = useMutation({ + mutationFn: async ({ + file, + kind, + }: { + file: File; + kind: UploadKind; + }) => { + const bytes = await file.arrayBuffer(); + if (kind === "proto") { + return uploadProtoDescriptor(operationId, file.name, bytes); + } + + return uploadDescriptorSet(operationId, file.name, bytes); + }, + onSuccess: async () => { + setUploadError(null); + await queryClient.invalidateQueries({ + queryKey: ["grpc-services", operationId, version], + }); + }, + onError: (error) => { + setUploadError( + error instanceof ApiError ? error.message : "Descriptor upload failed", + ); + }, + }); + + async function handleFileUpload( + file: File | undefined, + kind: UploadKind, + ) { + if (file === undefined) { + return; + } + + setUploadError(null); + uploadMutation.mutate({ file, kind }); + } + + return ( +
+
+ + +
+ + {uploadMutation.isPending ? ( +
Uploading descriptor artifact...
+ ) : null} + {uploadError ? ( +
{uploadError}
+ ) : null} + + {servicesQuery.data?.services.length ? ( +
+ {servicesQuery.data.services.map((service) => ( +
+
+
+

{service.service}

+

{descriptorSubtitle(service)}

+
+ + {service.methods.length} methods + +
+ +
+ {service.methods.map((method) => ( +
+
+ {method.name} + {method.kind} +
+
+
+

Input schema

+
{JSON.stringify(method.input_schema, null, 2)}
+
+
+

Output schema

+
{JSON.stringify(method.output_schema, null, 2)}
+
+
+
+ ))} +
+
+ ))} +
+ ) : ( +
+ Upload a descriptor set to inspect unary services and message schemas. +
+ )} +
+ ); +} diff --git a/apps/ui/src/features/operation-form/model.test.ts b/apps/ui/src/features/operation-form/model.test.ts index 4fffdab..dea833c 100644 --- a/apps/ui/src/features/operation-form/model.test.ts +++ b/apps/ui/src/features/operation-form/model.test.ts @@ -1,15 +1,13 @@ import { describe, expect, it } from "vitest"; -import { - buildOperationPayload, - defaultOperationFormValues, -} from "./model"; +import { buildOperationPayload, defaultOperationFormValues } from "./model"; describe("buildOperationPayload", () => { it("creates a REST operation payload from form values", () => { const payload = buildOperationPayload(defaultOperationFormValues); expect(payload.protocol).toBe("rest"); + expect(payload.target.kind).toBe("rest"); expect(payload.target.method).toBe("POST"); expect(payload.input_mapping).toEqual({ rules: [ @@ -22,6 +20,89 @@ describe("buildOperationPayload", () => { }); }); + it("creates a GraphQL operation payload from form values", () => { + const payload = buildOperationPayload({ + ...defaultOperationFormValues, + protocol: "graphql", + name: "crm_create_lead_graphql", + displayName: "Create Lead (GraphQL)", + toolTitle: "Create CRM lead through GraphQL", + inputMappingText: JSON.stringify( + { + rules: [ + { + source: "$.mcp.email", + target: "$.request.variables.email", + required: true, + }, + ], + }, + null, + 2, + ), + outputMappingText: JSON.stringify( + { + rules: [ + { + source: "$.response.body.data.createLead.id", + target: "$.output.id", + required: true, + }, + ], + }, + null, + 2, + ), + }); + + expect(payload.protocol).toBe("graphql"); + expect(payload.target.kind).toBe("graphql"); + expect(payload.target.operation_type).toBe("mutation"); + expect(payload.target.response_path).toBe("$.response.body.data.createLead"); + }); + + it("creates a gRPC operation payload from form values", () => { + const payload = buildOperationPayload({ + ...defaultOperationFormValues, + protocol: "grpc", + name: "crm_create_lead_grpc", + displayName: "Create Lead (gRPC)", + toolTitle: "Create CRM lead through gRPC", + grpcDescriptorSetB64: "ZGVzY3JpcHRvcg==", + inputMappingText: JSON.stringify( + { + rules: [ + { + source: "$.mcp.email", + target: "$.request.grpc.email", + required: true, + }, + ], + }, + null, + 2, + ), + outputMappingText: JSON.stringify( + { + rules: [ + { + source: "$.response.body.id", + target: "$.output.id", + required: true, + }, + ], + }, + null, + 2, + ), + }); + + expect(payload.protocol).toBe("grpc"); + expect(payload.target.kind).toBe("grpc"); + expect(payload.target.descriptor_ref).toBe("desc_lead_service"); + expect(payload.target.descriptor_set_b64).toBe("ZGVzY3JpcHRvcg=="); + }); + it("throws when JSON fields are invalid", () => { expect(() => buildOperationPayload({ diff --git a/apps/ui/src/features/operation-form/model.ts b/apps/ui/src/features/operation-form/model.ts index 7e48fb6..4a0d291 100644 --- a/apps/ui/src/features/operation-form/model.ts +++ b/apps/ui/src/features/operation-form/model.ts @@ -2,35 +2,117 @@ import { z } from "zod"; import { safeParseJson } from "../../shared/lib/json"; -const operationFormSchema = z.object({ - name: z - .string() - .min(3, "Name is too short") - .regex(/^[a-z0-9_]+$/, "Use lowercase snake_case"), - displayName: z.string().min(3, "Display name is too short"), - baseUrl: z.string().url("Base URL must be valid"), - method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), - pathTemplate: z.string().min(1, "Path is required"), - toolTitle: z.string().min(3, "Tool title is too short"), - toolDescription: z.string().min(8, "Tool description is too short"), - inputSchemaText: z.string().min(2, "Input schema is required"), - outputSchemaText: z.string().min(2, "Output schema is required"), - inputMappingText: z.string().min(2, "Input mapping is required"), - outputMappingText: z.string().min(2, "Output mapping is required"), - executionConfigText: z.string().min(2, "Execution config is required"), -}); +const jsonTextSchema = z.string().min(2, "JSON payload is required"); + +export const operationFormSchema = z + .object({ + protocol: z.enum(["rest", "graphql", "grpc"]), + name: z + .string() + .min(3, "Name is too short") + .regex(/^[a-z0-9_]+$/, "Use lowercase snake_case"), + displayName: z.string().min(3, "Display name is too short"), + toolTitle: z.string().min(3, "Tool title is too short"), + toolDescription: z.string().min(8, "Tool description is too short"), + restBaseUrl: z.string(), + restMethod: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), + restPathTemplate: z.string(), + graphqlEndpoint: z.string(), + graphqlOperationType: z.enum(["query", "mutation"]), + graphqlOperationName: z.string(), + graphqlQueryTemplate: z.string(), + graphqlResponsePath: z.string(), + grpcServerAddr: z.string(), + grpcPackage: z.string(), + grpcService: z.string(), + grpcMethod: z.string(), + grpcDescriptorRef: z.string(), + grpcDescriptorSetB64: z.string(), + inputSchemaText: jsonTextSchema, + outputSchemaText: jsonTextSchema, + inputMappingText: jsonTextSchema, + outputMappingText: jsonTextSchema, + executionConfigText: jsonTextSchema, + }) + .superRefine((values, context) => { + if (values.protocol === "rest") { + if (!z.string().url().safeParse(values.restBaseUrl).success) { + context.addIssue({ + code: "custom", + path: ["restBaseUrl"], + message: "Base URL must be valid", + }); + } + + if (values.restPathTemplate.trim().length === 0) { + context.addIssue({ + code: "custom", + path: ["restPathTemplate"], + message: "Path is required", + }); + } + } + + if (values.protocol === "graphql") { + if (!z.string().url().safeParse(values.graphqlEndpoint).success) { + context.addIssue({ + code: "custom", + path: ["graphqlEndpoint"], + message: "Endpoint must be valid", + }); + } + + if (values.graphqlOperationName.trim().length < 2) { + context.addIssue({ + code: "custom", + path: ["graphqlOperationName"], + message: "Operation name is required", + }); + } + + if (values.graphqlQueryTemplate.trim().length < 8) { + context.addIssue({ + code: "custom", + path: ["graphqlQueryTemplate"], + message: "Query template is too short", + }); + } + + if (values.graphqlResponsePath.trim().length < 4) { + context.addIssue({ + code: "custom", + path: ["graphqlResponsePath"], + message: "Response path is required", + }); + } + } + + if (values.protocol === "grpc") { + const requiredFields: Array<[keyof typeof values, string]> = [ + ["grpcServerAddr", "Server address is required"], + ["grpcPackage", "Package is required"], + ["grpcService", "Service is required"], + ["grpcMethod", "Method is required"], + ["grpcDescriptorRef", "Descriptor reference is required"], + ["grpcDescriptorSetB64", "Descriptor set base64 is required"], + ]; + + for (const [fieldName, message] of requiredFields) { + if (values[fieldName].trim().length === 0) { + context.addIssue({ + code: "custom", + path: [fieldName], + message, + }); + } + } + } + }); export type OperationFormValues = z.infer; -export const defaultOperationFormValues: OperationFormValues = { - name: "crm_create_lead", - displayName: "Create Lead", - baseUrl: "https://api.example.com", - method: "POST", - pathTemplate: "/v1/leads", - toolTitle: "Create CRM lead", - toolDescription: "Creates a CRM lead from MCP input fields.", - inputSchemaText: JSON.stringify( +function defaultInputSchemaText() { + return JSON.stringify( { type: "object", required: true, @@ -45,8 +127,11 @@ export const defaultOperationFormValues: OperationFormValues = { }, null, 2, - ), - outputSchemaText: JSON.stringify( + ); +} + +function defaultOutputSchemaText() { + return JSON.stringify( { type: "object", required: true, @@ -61,8 +146,11 @@ export const defaultOperationFormValues: OperationFormValues = { }, null, 2, - ), - inputMappingText: JSON.stringify( + ); +} + +function defaultRestInputMappingText() { + return JSON.stringify( { rules: [ { @@ -74,8 +162,11 @@ export const defaultOperationFormValues: OperationFormValues = { }, null, 2, - ), - outputMappingText: JSON.stringify( + ); +} + +function defaultRestOutputMappingText() { + return JSON.stringify( { rules: [ { @@ -87,44 +178,218 @@ export const defaultOperationFormValues: OperationFormValues = { }, null, 2, - ), - executionConfigText: JSON.stringify( + ); +} + +function defaultGraphqlInputMappingText() { + return JSON.stringify( + { + rules: [ + { + source: "$.mcp.email", + target: "$.request.variables.email", + required: true, + }, + ], + }, + null, + 2, + ); +} + +function defaultGraphqlOutputMappingText() { + return JSON.stringify( + { + rules: [ + { + source: "$.response.body.data.createLead.id", + target: "$.output.id", + required: true, + }, + ], + }, + null, + 2, + ); +} + +function defaultGrpcInputMappingText() { + return JSON.stringify( + { + rules: [ + { + source: "$.mcp.email", + target: "$.request.grpc.email", + required: true, + }, + ], + }, + null, + 2, + ); +} + +function defaultGrpcOutputMappingText() { + return JSON.stringify( + { + rules: [ + { + source: "$.response.body.id", + target: "$.output.id", + required: true, + }, + ], + }, + null, + 2, + ); +} + +function defaultExecutionConfigText() { + return JSON.stringify( { timeout_ms: 10000, headers: {}, }, null, 2, - ), + ); +} + +export const defaultOperationFormValues: OperationFormValues = { + protocol: "rest", + name: "crm_create_lead", + displayName: "Create Lead", + toolTitle: "Create CRM lead", + toolDescription: "Creates a CRM lead from MCP input fields.", + restBaseUrl: "https://api.example.com", + restMethod: "POST", + restPathTemplate: "/v1/leads", + graphqlEndpoint: "https://api.example.com/graphql", + graphqlOperationType: "mutation", + graphqlOperationName: "CreateLead", + graphqlQueryTemplate: `mutation CreateLead($email: String!) { + createLead(email: $email) { + id + status + } +}`, + graphqlResponsePath: "$.response.body.data.createLead", + grpcServerAddr: "https://grpc.example.com", + grpcPackage: "crm.v1", + grpcService: "LeadService", + grpcMethod: "CreateLead", + grpcDescriptorRef: "desc_lead_service", + grpcDescriptorSetB64: "", + inputSchemaText: defaultInputSchemaText(), + outputSchemaText: defaultOutputSchemaText(), + inputMappingText: defaultRestInputMappingText(), + outputMappingText: defaultRestOutputMappingText(), + executionConfigText: defaultExecutionConfigText(), }; +export function getProtocolPreset( + protocol: OperationFormValues["protocol"], +): Partial { + switch (protocol) { + case "rest": + return { + name: "crm_create_lead", + displayName: "Create Lead", + toolTitle: "Create CRM lead", + toolDescription: "Creates a CRM lead from MCP input fields.", + inputMappingText: defaultRestInputMappingText(), + outputMappingText: defaultRestOutputMappingText(), + }; + case "graphql": + return { + name: "crm_create_lead_graphql", + displayName: "Create Lead (GraphQL)", + toolTitle: "Create CRM lead through GraphQL", + toolDescription: + "Executes a fixed GraphQL mutation and returns the selected lead payload.", + inputMappingText: defaultGraphqlInputMappingText(), + outputMappingText: defaultGraphqlOutputMappingText(), + }; + case "grpc": + return { + name: "crm_create_lead_grpc", + displayName: "Create Lead (gRPC)", + toolTitle: "Create CRM lead through gRPC", + toolDescription: + "Executes a unary gRPC method using a descriptor-driven request contract.", + inputMappingText: defaultGrpcInputMappingText(), + outputMappingText: defaultGrpcOutputMappingText(), + }; + } +} + export function buildOperationPayload(rawValues: OperationFormValues) { const values = operationFormSchema.parse(rawValues); + const inputSchema = safeParseJson(values.inputSchemaText, "Input schema"); + const outputSchema = safeParseJson(values.outputSchemaText, "Output schema"); + const inputMapping = safeParseJson(values.inputMappingText, "Input mapping"); + const outputMapping = safeParseJson(values.outputMappingText, "Output mapping"); + const executionConfig = safeParseJson( + values.executionConfigText, + "Execution config", + ); - return { + const basePayload = { name: values.name, display_name: values.displayName, - protocol: "rest", - target: { - kind: "rest", - base_url: values.baseUrl, - method: values.method, - path_template: values.pathTemplate, - static_headers: {}, - }, - input_schema: safeParseJson(values.inputSchemaText, "Input schema"), - output_schema: safeParseJson(values.outputSchemaText, "Output schema"), - input_mapping: safeParseJson(values.inputMappingText, "Input mapping"), - output_mapping: safeParseJson(values.outputMappingText, "Output mapping"), - execution_config: safeParseJson( - values.executionConfigText, - "Execution config", - ), + input_schema: inputSchema, + output_schema: outputSchema, + input_mapping: inputMapping, + output_mapping: outputMapping, + execution_config: executionConfig, tool_description: { title: values.toolTitle, description: values.toolDescription, - tags: ["rest"], + tags: [values.protocol], examples: [], }, }; + + switch (values.protocol) { + case "rest": + return { + ...basePayload, + protocol: "rest", + target: { + kind: "rest", + base_url: values.restBaseUrl, + method: values.restMethod, + path_template: values.restPathTemplate, + static_headers: {}, + }, + }; + case "graphql": + return { + ...basePayload, + protocol: "graphql", + target: { + kind: "graphql", + endpoint: values.graphqlEndpoint, + operation_type: values.graphqlOperationType, + operation_name: values.graphqlOperationName, + query_template: values.graphqlQueryTemplate, + response_path: values.graphqlResponsePath, + }, + }; + case "grpc": + return { + ...basePayload, + protocol: "grpc", + target: { + kind: "grpc", + server_addr: values.grpcServerAddr, + package: values.grpcPackage, + service: values.grpcService, + method: values.grpcMethod, + descriptor_ref: values.grpcDescriptorRef, + descriptor_set_b64: values.grpcDescriptorSetB64, + }, + }; + } } diff --git a/apps/ui/src/features/operation-form/operation-form.tsx b/apps/ui/src/features/operation-form/operation-form.tsx index 27a5c0d..947acd4 100644 --- a/apps/ui/src/features/operation-form/operation-form.tsx +++ b/apps/ui/src/features/operation-form/operation-form.tsx @@ -1,39 +1,155 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; -import { z } from "zod"; import { createOperation } from "../../entities/operation/api"; import { ApiError } from "../../shared/api/client"; import { buildOperationPayload, defaultOperationFormValues, + getProtocolPreset, + operationFormSchema, type OperationFormValues, } from "./model"; -const formResolverSchema = z.object({ - name: z.string().min(3), - displayName: z.string().min(3), - baseUrl: z.string().url(), - method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), - pathTemplate: z.string().min(1), - toolTitle: z.string().min(3), - toolDescription: z.string().min(8), - inputSchemaText: z.string().min(2), - outputSchemaText: z.string().min(2), - inputMappingText: z.string().min(2), - outputMappingText: z.string().min(2), - executionConfigText: z.string().min(2), -}); +type OperationFieldName = keyof OperationFormValues; + +type FieldConfig = { + name: OperationFieldName; + label: string; + description?: string; + wide?: boolean; + rows?: number; +}; + +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.", + rows: 4, + wide: true, + }, +]; + +const restFields: FieldConfig[] = [ + { name: "restBaseUrl", label: "Base URL" }, + { name: "restMethod", label: "HTTP method" }, + { name: "restPathTemplate", label: "Path template" }, +]; + +const graphqlFields: FieldConfig[] = [ + { name: "graphqlEndpoint", label: "GraphQL endpoint" }, + { 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, + wide: true, + }, + { + name: "graphqlResponsePath", + label: "Response path", + description: "JSONPath-like extraction root inside response preview.", + wide: true, + }, +]; + +const grpcFields: FieldConfig[] = [ + { name: "grpcServerAddr", label: "Server address" }, + { 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.", + }, + { + name: "grpcDescriptorSetB64", + label: "Descriptor set base64", + description: "Use a descriptor-set file to prefill this field before creation.", + rows: 8, + wide: true, + }, +]; + +const schemaFields: FieldConfig[] = [ + { name: "inputSchemaText", label: "Input schema", rows: 12, wide: true }, + { name: "outputSchemaText", label: "Output schema", rows: 12, wide: true }, +]; + +const mappingFields: FieldConfig[] = [ + { name: "inputMappingText", label: "Input mapping", rows: 12, wide: true }, + { name: "outputMappingText", label: "Output mapping", rows: 12, wide: true }, + { + name: "executionConfigText", + label: "Execution config", + rows: 10, + wide: true, + }, +]; + +function protocolSummary(protocol: OperationFormValues["protocol"]) { + switch (protocol) { + case "rest": + return "One MCP 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."; + case "grpc": + return "One MCP tool maps to one unary gRPC method backed by a descriptor set embedded at creation time."; + } +} + +function descriptorRefFromFileName(fileName: string) { + const normalized = fileName + .replace(/\.[^/.]+$/, "") + .replace(/[^a-zA-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + + return normalized.length === 0 ? "desc_uploaded" : `desc_${normalized}`; +} + +function bytesToBase64(buffer: ArrayBuffer) { + const bytes = new Uint8Array(buffer); + let binary = ""; + + for (let index = 0; index < bytes.length; index += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000)); + } + + return window.btoa(binary); +} export function OperationForm() { const navigate = useNavigate(); const form = useForm({ defaultValues: defaultOperationFormValues, - resolver: zodResolver(formResolverSchema), + resolver: zodResolver(operationFormSchema), }); + const activeProtocol = form.watch("protocol"); + const previousProtocolRef = useRef(activeProtocol); + + useEffect(() => { + if (previousProtocolRef.current === activeProtocol) { + return; + } + + const preset = getProtocolPreset(activeProtocol); + for (const [fieldName, value] of Object.entries(preset)) { + form.setValue(fieldName as OperationFieldName, value); + } + + previousProtocolRef.current = activeProtocol; + }, [activeProtocol, form]); const creationMutation = useMutation({ mutationFn: async (values: OperationFormValues) => @@ -43,17 +159,99 @@ export function OperationForm() { }, }); - type OperationFieldName = keyof OperationFormValues; + const protocolFields = useMemo(() => { + switch (activeProtocol) { + case "rest": + return restFields; + case "graphql": + return graphqlFields; + case "grpc": + return grpcFields; + } + }, [activeProtocol]); - const fieldGroups = useMemo( - () => [ - ["name", "displayName", "baseUrl", "method", "pathTemplate"], - ["toolTitle", "toolDescription"], - ["inputSchemaText", "outputSchemaText"], - ["inputMappingText", "outputMappingText", "executionConfigText"], - ], - [], - ); + async function handleDescriptorFileChange(file: File | undefined) { + if (file === undefined) { + return; + } + + const encoded = bytesToBase64(await file.arrayBuffer()); + form.setValue("grpcDescriptorSetB64", encoded, { + shouldDirty: true, + shouldValidate: true, + }); + + if (form.getValues("grpcDescriptorRef").trim().length === 0) { + form.setValue("grpcDescriptorRef", descriptorRefFromFileName(file.name), { + shouldDirty: true, + }); + } + } + + function renderField(field: FieldConfig) { + const fieldError = form.formState.errors[field.name]; + const message = + typeof fieldError?.message === "string" ? fieldError.message : undefined; + + if ( + field.name === "restMethod" || + field.name === "graphqlOperationType" || + field.name === "protocol" + ) { + return ( + + ); + } + + const isTextarea = field.rows !== undefined; + + return ( +