Merge branch 'feat/ui-concept-alignment'
This commit is contained in:
@@ -2,17 +2,17 @@
|
||||
|
||||
## Current
|
||||
|
||||
### `feat/ui-ux-pass`
|
||||
### `feat/ui-concept-alignment`
|
||||
|
||||
Status: completed
|
||||
|
||||
DoD:
|
||||
|
||||
- shell выглядит как цельный продуктовый интерфейс, а не как набор отдельных секций
|
||||
- operation list показывает обзор состояния системы, а не только список карточек
|
||||
- create page объясняет оператору различия между `REST`, `GraphQL` и `gRPC`
|
||||
- ключевые сценарии имеют пустые состояния и более понятную визуальную иерархию
|
||||
- UI build и tests остаются зелеными после UX-pass
|
||||
- shell и create flow визуально опираются на утвержденный concept
|
||||
- sidebar, topbar и page header выглядят как единая продуктовая система
|
||||
- create page больше не выглядит как длинная сырая форма, а как step-based contract builder
|
||||
- кодовые JSON и mapping поля имеют отдельную визуальную подачу
|
||||
- UI build и tests остаются зелеными после выравнивания под concept
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof operationFormSchema>;
|
||||
|
||||
function parseStringMap(raw: string, fieldName: string) {
|
||||
const value = safeParseJson<Record<string, unknown>>(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<string, string>;
|
||||
}
|
||||
|
||||
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<Record<string, unknown>>(
|
||||
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":
|
||||
|
||||
@@ -22,89 +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: "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 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.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,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 (
|
||||
<label
|
||||
className={field.wide ? "field-block field-block-wide" : "field-block"}
|
||||
key={field.name}
|
||||
>
|
||||
<span>{field.label}</span>
|
||||
{field.description ? <small className="field-hint">{field.description}</small> : null}
|
||||
<select {...form.register(field.name)}>
|
||||
{field.name === "protocol" ? (
|
||||
<>
|
||||
<option value="rest">REST</option>
|
||||
<option value="graphql">GraphQL</option>
|
||||
<option value="grpc">gRPC</option>
|
||||
</>
|
||||
) : null}
|
||||
{field.name === "restMethod" ? (
|
||||
<>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</>
|
||||
) : null}
|
||||
{field.name === "graphqlOperationType" ? (
|
||||
<>
|
||||
<option value="query">query</option>
|
||||
<option value="mutation">mutation</option>
|
||||
</>
|
||||
) : null}
|
||||
</select>
|
||||
{message ? <small className="field-error">{message}</small> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const isTextarea = field.rows !== undefined;
|
||||
function renderSelect(field: FieldConfig) {
|
||||
const message = fieldError(field.name);
|
||||
|
||||
return (
|
||||
<label
|
||||
@@ -243,11 +269,70 @@ export function OperationForm() {
|
||||
>
|
||||
<span>{field.label}</span>
|
||||
{field.description ? <small className="field-hint">{field.description}</small> : null}
|
||||
{isTextarea ? (
|
||||
<textarea rows={field.rows} {...form.register(field.name)} />
|
||||
) : (
|
||||
<input type="text" {...form.register(field.name)} />
|
||||
)}
|
||||
<select className="select" {...form.register(field.name)}>
|
||||
{field.name === "restMethod" ? (
|
||||
<>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</>
|
||||
) : null}
|
||||
{field.name === "graphqlOperationType" ? (
|
||||
<>
|
||||
<option value="query">query</option>
|
||||
<option value="mutation">mutation</option>
|
||||
</>
|
||||
) : null}
|
||||
</select>
|
||||
{message ? <small className="field-error">{message}</small> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function renderField(field: FieldConfig) {
|
||||
if (field.name === "restMethod" || field.name === "graphqlOperationType") {
|
||||
return renderSelect(field);
|
||||
}
|
||||
|
||||
const message = fieldError(field.name);
|
||||
const blockClassName = field.wide ? "field-block field-block-wide" : "field-block";
|
||||
|
||||
if (field.rows !== undefined) {
|
||||
return (
|
||||
<label className={blockClassName} key={field.name}>
|
||||
<span>{field.label}</span>
|
||||
{field.description ? <small className="field-hint">{field.description}</small> : null}
|
||||
{field.code ? (
|
||||
<div className="code-editor-shell">
|
||||
<div className="code-editor-toolbar">
|
||||
<div className="code-editor-dots">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<small>json / contract</small>
|
||||
</div>
|
||||
<textarea
|
||||
className="textarea textarea-code"
|
||||
rows={field.rows}
|
||||
{...form.register(field.name)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<textarea rows={field.rows} {...form.register(field.name)} />
|
||||
)}
|
||||
{message ? <small className="field-error">{message}</small> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={blockClassName} key={field.name}>
|
||||
<span>{field.label}</span>
|
||||
{field.description ? <small className="field-hint">{field.description}</small> : null}
|
||||
<input type="text" className="input" {...form.register(field.name)} />
|
||||
{message ? <small className="field-error">{message}</small> : null}
|
||||
</label>
|
||||
);
|
||||
@@ -255,56 +340,155 @@ export function OperationForm() {
|
||||
|
||||
return (
|
||||
<form
|
||||
className="stack-layout"
|
||||
className="builder-stack"
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
creationMutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
<div className="protocol-selector-card">
|
||||
<div className="form-grid">
|
||||
{renderField({ name: "protocol", label: "Protocol" })}
|
||||
<div className="protocol-brief">
|
||||
<span className={`protocol-pill protocol-${activeProtocol}`}>{activeProtocol}</span>
|
||||
<p>{protocolSummary(activeProtocol)}</p>
|
||||
<section className="builder-card">
|
||||
<header className="builder-card-header">
|
||||
<div>
|
||||
<h2>1 — Protocol</h2>
|
||||
<p>One tool maps to exactly one upstream method.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="builder-card-body">
|
||||
<div className="proto-grid">
|
||||
{protocolCards.map((card) => {
|
||||
const isSelected = activeProtocol === card.protocol;
|
||||
const selectedClassName = isSelected
|
||||
? `proto-card selected-${card.protocol === "graphql" ? "gql" : card.protocol}`
|
||||
: "proto-card";
|
||||
|
||||
return (
|
||||
<button
|
||||
className={selectedClassName}
|
||||
key={card.protocol}
|
||||
onClick={() => {
|
||||
form.setValue("protocol", card.protocol, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="proto-card-top">
|
||||
<div className={`proto-icon pi-${card.protocol === "graphql" ? "gql" : card.protocol}`}>
|
||||
{card.protocol === "graphql" ? "GQL" : card.protocol.toUpperCase()}
|
||||
</div>
|
||||
<div className="proto-radio" />
|
||||
</div>
|
||||
<div className="proto-card-copy">
|
||||
<h3>{card.title}</h3>
|
||||
<p>{card.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="form-grid">{commonFields.map(renderField)}</div>
|
||||
<section className="builder-card">
|
||||
<header className="builder-card-header">
|
||||
<div>
|
||||
<h2>2 — Tool identity</h2>
|
||||
<p>Name and description visible to the LLM at runtime.</p>
|
||||
</div>
|
||||
<span className="status-pill status-draft">draft</span>
|
||||
</header>
|
||||
<div className="builder-card-body">
|
||||
<div className="form-grid">{commonFields.map(renderField)}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="page-subsection">
|
||||
<div className="page-subsection-header">
|
||||
<h3>Target</h3>
|
||||
<p>Protocol-specific transport configuration exposed through one MCP tool.</p>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
{protocolFields.map(renderField)}
|
||||
{activeProtocol === "grpc" ? (
|
||||
<label className="field-block field-block-wide">
|
||||
<span>Descriptor set file</span>
|
||||
<small className="field-hint">
|
||||
Upload a compiled descriptor-set file to autofill the base64 payload.
|
||||
</small>
|
||||
<input
|
||||
type="file"
|
||||
accept=".bin,.pb,.desc"
|
||||
onChange={(event) => {
|
||||
void handleDescriptorFileChange(event.target.files?.[0]);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<section className="builder-card">
|
||||
<header className="builder-card-header">
|
||||
<div>
|
||||
<h2>3 — Upstream target</h2>
|
||||
<p>Protocol-specific target configuration exposed through one MCP tool.</p>
|
||||
</div>
|
||||
<span className={`protocol-pill protocol-${activeProtocol}`}>{activeProtocol}</span>
|
||||
</header>
|
||||
<div className="builder-card-body">
|
||||
<div className="form-grid">
|
||||
{protocolFields.map(renderField)}
|
||||
{activeProtocol === "grpc" ? (
|
||||
<label className="field-block field-block-wide">
|
||||
<span>Descriptor set file</span>
|
||||
<small className="field-hint">
|
||||
Upload a compiled descriptor-set file to autofill the base64 payload.
|
||||
</small>
|
||||
<input
|
||||
className="input"
|
||||
type="file"
|
||||
accept=".bin,.pb,.desc"
|
||||
onChange={(event) => {
|
||||
void handleDescriptorFileChange(event.target.files?.[0]);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{activeProtocol === "rest" ? renderField({
|
||||
name: "restStaticHeadersText",
|
||||
label: "Static headers",
|
||||
description:
|
||||
"Always sent for this REST target before dynamic request headers are merged.",
|
||||
rows: 8,
|
||||
wide: true,
|
||||
code: true,
|
||||
}) : null}
|
||||
</div>
|
||||
|
||||
<div className="page-subsection">
|
||||
<div className="page-subsection-header">
|
||||
<h3>Contract</h3>
|
||||
<p>Schemas and mapping stay JSON-based regardless of the upstream protocol.</p>
|
||||
<div className="info-banner">
|
||||
<div className="info-dot" />
|
||||
<p>
|
||||
Headers and auth stay separate from path and payload mapping. Configure shared
|
||||
transport headers in the execution section below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-grid">{schemaFields.map(renderField)}</div>
|
||||
<div className="form-grid">{mappingFields.map(renderField)}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="builder-card">
|
||||
<header className="builder-card-header">
|
||||
<div>
|
||||
<h2>4 — Contract schemas</h2>
|
||||
<p>Protocol-agnostic schemas for MCP input and output payloads.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="builder-card-body">
|
||||
<div className="form-grid">{schemaFields.map(renderField)}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="builder-card">
|
||||
<header className="builder-card-header">
|
||||
<div>
|
||||
<h2>5 — Mapping and execution</h2>
|
||||
<p>Translate MCP input to request fields, then map upstream output back to tool output.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="builder-card-body">
|
||||
<div className="section-divider">
|
||||
<span className="section-divider-label">Input → Request</span>
|
||||
<div className="section-divider-line" />
|
||||
</div>
|
||||
<div className="form-grid">{mappingFields.slice(0, 1).map(renderField)}</div>
|
||||
|
||||
<div className="section-divider">
|
||||
<span className="section-divider-label">Response → Output</span>
|
||||
<div className="section-divider-line" />
|
||||
</div>
|
||||
<div className="form-grid">{mappingFields.slice(1).map(renderField)}</div>
|
||||
|
||||
<div className="section-divider">
|
||||
<span className="section-divider-label">Headers And Runtime</span>
|
||||
<div className="section-divider-line" />
|
||||
</div>
|
||||
<div className="form-grid">{headerFields.map(renderField)}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{creationMutation.error ? (
|
||||
<div className="feedback-card feedback-error">
|
||||
@@ -314,8 +498,12 @@ export function OperationForm() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="button-row">
|
||||
<button className="button-primary" type="submit" disabled={creationMutation.isPending}>
|
||||
<div className="sticky-action-bar">
|
||||
<div className="sticky-action-copy">
|
||||
<span className="status-pill status-testing">ready to publish later</span>
|
||||
<p>{protocolSummary(activeProtocol)}</p>
|
||||
</div>
|
||||
<button className="button-primary button-primary-strong" type="submit" disabled={creationMutation.isPending}>
|
||||
{creationMutation.isPending ? "Creating..." : "Create operation"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,53 +1,9 @@
|
||||
import { OperationForm } from "../../features/operation-form/operation-form";
|
||||
import { PageSection } from "../../shared/ui/page-section";
|
||||
|
||||
const protocolCards = [
|
||||
{
|
||||
protocol: "rest",
|
||||
title: "REST",
|
||||
description:
|
||||
"One tool maps to one method and one path, with body, query and header mapping.",
|
||||
},
|
||||
{
|
||||
protocol: "graphql",
|
||||
title: "GraphQL",
|
||||
description:
|
||||
"One tool maps to one fixed query or mutation with stable variables and response shape.",
|
||||
},
|
||||
{
|
||||
protocol: "grpc",
|
||||
title: "gRPC",
|
||||
description:
|
||||
"One tool maps to one unary method backed by a descriptor-set contract.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function OperationCreatePage() {
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<PageSection
|
||||
title="Create Operation"
|
||||
subtitle="Create one MCP tool from one fixed REST, GraphQL or unary gRPC contract."
|
||||
>
|
||||
<div className="hero-grid">
|
||||
{protocolCards.map((card) => (
|
||||
<article className="hero-card" key={card.protocol}>
|
||||
<span className={`protocol-pill protocol-${card.protocol}`}>
|
||||
{card.protocol}
|
||||
</span>
|
||||
<h3>{card.title}</h3>
|
||||
<p className="body-copy">{card.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
<PageSection
|
||||
title="Contract Builder"
|
||||
subtitle="Define the operator-facing tool contract first, then map it to the upstream target."
|
||||
>
|
||||
<OperationForm />
|
||||
</PageSection>
|
||||
<OperationForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
function draftSubtitle(target: OperationTarget) {
|
||||
switch (target.kind) {
|
||||
case "rest":
|
||||
@@ -147,6 +166,16 @@ export function OperationDetailPage() {
|
||||
<h3>Execution config</h3>
|
||||
<pre>{JSON.stringify(snapshot.execution_config, null, 2)}</pre>
|
||||
</div>
|
||||
<div className="result-panel">
|
||||
<h3>Transport headers</h3>
|
||||
<pre>{JSON.stringify(readExecutionHeaders(snapshot.execution_config), null, 2)}</pre>
|
||||
</div>
|
||||
{snapshot.target.kind === "rest" ? (
|
||||
<div className="result-panel">
|
||||
<h3>REST static headers</h3>
|
||||
<pre>{JSON.stringify(snapshot.target.static_headers ?? {}, null, 2)}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PageSection>
|
||||
) : null}
|
||||
|
||||
@@ -1,108 +1,133 @@
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { ReactNode } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
|
||||
type AppShellProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function pageTitle(pathname: string) {
|
||||
if (pathname.startsWith("/operations/new")) {
|
||||
function routeMeta(pathname: string) {
|
||||
if (pathname === "/operations/new") {
|
||||
return {
|
||||
title: "Create tool contract",
|
||||
section: "Operations",
|
||||
title: "New Operation",
|
||||
heading: "Create tool contract",
|
||||
subtitle:
|
||||
"Configure one fixed upstream contract and publish it as one MCP tool.",
|
||||
"Define a single upstream endpoint and expose it as one MCP tool. Choose a protocol, fill the contract and map fields.",
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/operations/")) {
|
||||
return {
|
||||
title: "Operation workspace",
|
||||
section: "Operations",
|
||||
title: "Operation Workspace",
|
||||
heading: "Refine operation contract",
|
||||
subtitle:
|
||||
"Refine target metadata, validate mappings and publish the current draft.",
|
||||
"Inspect target metadata, validate mappings, run tests and publish the active draft version.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Operation catalog",
|
||||
section: "Operations",
|
||||
title: "Catalog",
|
||||
heading: "Operation catalog",
|
||||
subtitle:
|
||||
"Track draft and published tools across REST, GraphQL and unary gRPC.",
|
||||
"Track draft and published tool contracts across REST, GraphQL and unary gRPC.",
|
||||
};
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const location = useLocation();
|
||||
const currentPage = pageTitle(location.pathname);
|
||||
const meta = routeMeta(location.pathname);
|
||||
|
||||
return (
|
||||
<div className="layout-shell">
|
||||
<aside className="layout-sidebar">
|
||||
<div className="brand-block">
|
||||
<p className="eyebrow">RMCP Console</p>
|
||||
<h1>Tool Console</h1>
|
||||
<p className="brand-copy">
|
||||
Low-code operator surface for REST, GraphQL and unary gRPC tool
|
||||
onboarding, draft generation and publish flow.
|
||||
</p>
|
||||
<div className="sidebar-logo">
|
||||
<div className="logo-mark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
|
||||
<path d="M12 2 2 7l10 5 10-5-10-5Z" />
|
||||
<path d="m2 12 10 5 10-5" />
|
||||
<path d="m2 17 10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="logo-text">
|
||||
<strong>MCPaaS</strong>
|
||||
<span>Tool Console</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<p className="nav-section-title">Workspace</p>
|
||||
<NavLink
|
||||
to="/operations"
|
||||
className={({ isActive }) =>
|
||||
isActive ? "nav-link nav-link-active" : "nav-link"
|
||||
}
|
||||
>
|
||||
<strong>Operations</strong>
|
||||
<span>Catalog, status, search and entry points.</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/operations/new"
|
||||
className={({ isActive }) =>
|
||||
isActive ? "nav-link nav-link-active" : "nav-link"
|
||||
}
|
||||
>
|
||||
<strong>New Operation</strong>
|
||||
<span>Create a protocol-specific contract and publishable tool.</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className="sidebar-section">
|
||||
<p className="sidebar-label">Workspace</p>
|
||||
<nav className="sidebar-nav">
|
||||
<NavLink
|
||||
to="/operations"
|
||||
className={({ isActive }) =>
|
||||
isActive ? "nav-link nav-link-active" : "nav-link"
|
||||
}
|
||||
>
|
||||
<div className="nav-copy">
|
||||
<strong>Operations</strong>
|
||||
<span>Catalog, status and search across all tools.</span>
|
||||
</div>
|
||||
<span className="nav-badge">live</span>
|
||||
</NavLink>
|
||||
|
||||
<div className="sidebar-meta">
|
||||
<div className="sidebar-card">
|
||||
<p className="eyebrow">Protocols</p>
|
||||
<div className="sidebar-pill-row">
|
||||
<span className="protocol-pill protocol-rest">rest</span>
|
||||
<span className="protocol-pill protocol-graphql">graphql</span>
|
||||
<span className="protocol-pill protocol-grpc">grpc</span>
|
||||
</div>
|
||||
<NavLink
|
||||
to="/operations/new"
|
||||
className={({ isActive }) =>
|
||||
isActive ? "nav-link nav-link-active" : "nav-link"
|
||||
}
|
||||
>
|
||||
<div className="nav-copy">
|
||||
<strong>New Operation</strong>
|
||||
<span>Create a new protocol-specific tool contract.</span>
|
||||
</div>
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-divider" />
|
||||
|
||||
<div className="sidebar-section">
|
||||
<p className="sidebar-label">Protocols</p>
|
||||
<div className="sidebar-pill-row">
|
||||
<span className="protocol-pill protocol-rest">REST</span>
|
||||
<span className="protocol-pill protocol-graphql">GraphQL</span>
|
||||
<span className="protocol-pill protocol-grpc">gRPC</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-card">
|
||||
<p className="eyebrow">Lifecycle</p>
|
||||
<div className="sidebar-pill-row">
|
||||
<span className="status-pill status-draft">draft</span>
|
||||
<span className="status-pill status-published">published</span>
|
||||
</div>
|
||||
<div className="sidebar-footer">
|
||||
<div className="sidebar-user-avatar" />
|
||||
<div className="sidebar-user-copy">
|
||||
<strong>Operator</strong>
|
||||
<span>Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="layout-main">
|
||||
<header className="workspace-header">
|
||||
<div>
|
||||
<p className="eyebrow">Operator Workspace</p>
|
||||
<h2>{currentPage.title}</h2>
|
||||
<p>{currentPage.subtitle}</p>
|
||||
<div className="layout-main">
|
||||
<header className="topbar">
|
||||
<div className="breadcrumb">
|
||||
<span>{meta.section}</span>
|
||||
<span className="breadcrumb-separator">/</span>
|
||||
<strong>{meta.title}</strong>
|
||||
</div>
|
||||
<div className="workspace-badges">
|
||||
<div className="topbar-actions">
|
||||
<span className="workspace-badge">MCP request-response</span>
|
||||
<span className="workspace-badge">JSONPath mappings</span>
|
||||
<span className="workspace-badge">YAML import/export</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{children}
|
||||
</main>
|
||||
<main className="main-content">
|
||||
<div className="page-header">
|
||||
<h1>{meta.heading}</h1>
|
||||
<p>{meta.subtitle}</p>
|
||||
</div>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+810
-434
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user