@12-apps/mcp 1.0.0 → 1.3.0
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.
- package/package.json +28 -16
- package/src/dispatch/proxy.test.ts +61 -10
- package/src/guide.ts +253 -0
- package/src/index.ts +17 -0
- package/src/openapi/generate.test.ts +123 -11
- package/src/openapi/generate.ts +66 -8
- package/src/react/ai-capabilities.tsx +99 -0
- package/src/react/ai-connection-utils.ts +96 -0
- package/src/react/ai-flow-steps.tsx +290 -0
- package/src/react/ai-icons.tsx +70 -0
- package/src/react/ai-landing.tsx +125 -0
- package/src/react/ai-onboarding.tsx +147 -0
- package/src/react/ai-status-board.tsx +124 -0
- package/src/react/ai-steps.tsx +167 -0
- package/src/react/feature-badge.tsx +46 -0
- package/src/react/host-connect-guide.tsx +225 -0
- package/src/react/host-select-step.tsx +109 -0
- package/src/react/index.ts +43 -0
- package/src/react/mcp-endpoint-url.tsx +24 -0
- package/src/server/manifest.test.ts +29 -9
- package/src/server/redact.test.ts +47 -0
- package/src/server/redact.ts +47 -0
- package/src/server/registry.test.ts +113 -4
- package/src/server/registry.ts +64 -6
- package/src/types.ts +29 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import CheckIcon from "@mui/icons-material/Check";
|
|
4
|
+
import ContentCopyOutlinedIcon from "@mui/icons-material/ContentCopyOutlined";
|
|
5
|
+
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
|
|
8
|
+
import { Button } from "@12-apps/ui/form/Button";
|
|
9
|
+
import { Box } from "@12-apps/ui/mui/Box";
|
|
10
|
+
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
11
|
+
import { Text } from "@12-apps/ui/typography/Text";
|
|
12
|
+
|
|
13
|
+
import type { AiHostGuide } from "../guide";
|
|
14
|
+
import { HostBrandAvatar } from "./ai-icons";
|
|
15
|
+
import { McpEndpointUrl } from "./mcp-endpoint-url";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The store's MCP endpoint URL with a blue "Copiar" button (the display-only
|
|
19
|
+
* field defers the copy to this button). Controlled: `copied` flips the label to
|
|
20
|
+
* "Copiado" and `onCopy` fires after the clipboard write — the wizard uses it to
|
|
21
|
+
* unlock the "Próximo" button on the Copiar-URL step.
|
|
22
|
+
*/
|
|
23
|
+
export function EndpointCopyBlock({
|
|
24
|
+
endpointUrl,
|
|
25
|
+
copied,
|
|
26
|
+
onCopy,
|
|
27
|
+
}: {
|
|
28
|
+
endpointUrl: string;
|
|
29
|
+
copied: boolean;
|
|
30
|
+
onCopy: () => void;
|
|
31
|
+
}): React.JSX.Element {
|
|
32
|
+
const handleClick = (): void => {
|
|
33
|
+
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
|
34
|
+
void navigator.clipboard.writeText(endpointUrl).catch(() => undefined);
|
|
35
|
+
}
|
|
36
|
+
onCopy();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Box
|
|
41
|
+
sx={{
|
|
42
|
+
p: 2,
|
|
43
|
+
borderRadius: 2,
|
|
44
|
+
border: 1,
|
|
45
|
+
borderColor: "primary.main",
|
|
46
|
+
bgcolor: "action.hover",
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<Text variant="caption" as="p" weight="bold">
|
|
50
|
+
URL do servidor da sua loja
|
|
51
|
+
</Text>
|
|
52
|
+
<Text variant="caption" as="p" color="secondary">
|
|
53
|
+
Copie esta URL e cole no seu assistente para conectá-lo à loja.
|
|
54
|
+
</Text>
|
|
55
|
+
<Box sx={{ mt: 1 }}>
|
|
56
|
+
<McpEndpointUrl url={endpointUrl} copyable={false} />
|
|
57
|
+
</Box>
|
|
58
|
+
<Box sx={{ mt: 1.5 }}>
|
|
59
|
+
<Button onClick={handleClick} data-testid="ai-copy-endpoint">
|
|
60
|
+
{copied ? (
|
|
61
|
+
<CheckIcon sx={{ fontSize: 18, mr: 0.5 }} />
|
|
62
|
+
) : (
|
|
63
|
+
<ContentCopyOutlinedIcon sx={{ fontSize: 18, mr: 0.5 }} />
|
|
64
|
+
)}
|
|
65
|
+
{copied ? "Copiado" : "Copiar URL"}
|
|
66
|
+
</Button>
|
|
67
|
+
</Box>
|
|
68
|
+
</Box>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A copyable multi-line message block (preserves line breaks). Used on the
|
|
74
|
+
* Conectar step for the prompt the owner pastes into the assistant so it
|
|
75
|
+
* identifies itself and calls a tool (which registers the connection).
|
|
76
|
+
*/
|
|
77
|
+
export function PromptCopyBlock({
|
|
78
|
+
title,
|
|
79
|
+
caption,
|
|
80
|
+
message,
|
|
81
|
+
}: {
|
|
82
|
+
title: string;
|
|
83
|
+
caption: string;
|
|
84
|
+
message: string;
|
|
85
|
+
}): React.JSX.Element {
|
|
86
|
+
const [copied, setCopied] = useState(false);
|
|
87
|
+
|
|
88
|
+
const handleClick = (): void => {
|
|
89
|
+
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
|
90
|
+
void navigator.clipboard.writeText(message).catch(() => undefined);
|
|
91
|
+
}
|
|
92
|
+
setCopied(true);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Box
|
|
97
|
+
sx={{
|
|
98
|
+
p: 2,
|
|
99
|
+
borderRadius: 2,
|
|
100
|
+
border: 1,
|
|
101
|
+
borderColor: "primary.main",
|
|
102
|
+
bgcolor: "action.hover",
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<Text variant="caption" as="p" weight="bold">
|
|
106
|
+
{title}
|
|
107
|
+
</Text>
|
|
108
|
+
<Text variant="caption" as="p" color="secondary">
|
|
109
|
+
{caption}
|
|
110
|
+
</Text>
|
|
111
|
+
<Box
|
|
112
|
+
sx={{
|
|
113
|
+
mt: 1,
|
|
114
|
+
p: 1.5,
|
|
115
|
+
borderRadius: 1,
|
|
116
|
+
border: 1,
|
|
117
|
+
borderColor: "divider",
|
|
118
|
+
bgcolor: "background.paper",
|
|
119
|
+
whiteSpace: "pre-line",
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<Text variant="body" size="sm" as="p">
|
|
123
|
+
{message}
|
|
124
|
+
</Text>
|
|
125
|
+
</Box>
|
|
126
|
+
<Box sx={{ mt: 1.5 }}>
|
|
127
|
+
<Button onClick={handleClick} data-testid="ai-copy-prompt">
|
|
128
|
+
{copied ? (
|
|
129
|
+
<CheckIcon sx={{ fontSize: 18, mr: 0.5 }} />
|
|
130
|
+
) : (
|
|
131
|
+
<ContentCopyOutlinedIcon sx={{ fontSize: 18, mr: 0.5 }} />
|
|
132
|
+
)}
|
|
133
|
+
{copied ? "Copiado" : "Copiar mensagem"}
|
|
134
|
+
</Button>
|
|
135
|
+
</Box>
|
|
136
|
+
</Box>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The brand mark + "Conectar no <host>" header shared by the connect sub-steps. */
|
|
141
|
+
export function HostConnectHeader({ host }: { host: AiHostGuide }): React.JSX.Element {
|
|
142
|
+
return (
|
|
143
|
+
<Stack direction="row" spacing={1.5} alignItems="center">
|
|
144
|
+
<HostBrandAvatar brand={host.brand} size={36} />
|
|
145
|
+
<Text variant="heading" size="sm" as="h3">
|
|
146
|
+
Conectar no {host.label}
|
|
147
|
+
</Text>
|
|
148
|
+
</Stack>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The primary direct-link button to a host's connector settings (nothing when it
|
|
154
|
+
* has none). `onOpen` fires after the link opens so the Configurar step can
|
|
155
|
+
* unlock its "Próximo" only once the owner has actually gone to the connectors.
|
|
156
|
+
*/
|
|
157
|
+
export function HostOpenButton({
|
|
158
|
+
host,
|
|
159
|
+
onOpen,
|
|
160
|
+
}: {
|
|
161
|
+
host: AiHostGuide;
|
|
162
|
+
onOpen?: () => void;
|
|
163
|
+
}): React.JSX.Element | null {
|
|
164
|
+
if (!host.link) return null;
|
|
165
|
+
return (
|
|
166
|
+
<Box>
|
|
167
|
+
<Button
|
|
168
|
+
onClick={() => {
|
|
169
|
+
window.open(host.link!.url, "_blank", "noopener,noreferrer");
|
|
170
|
+
onOpen?.();
|
|
171
|
+
}}
|
|
172
|
+
data-testid={`ai-host-link-${host.id}`}
|
|
173
|
+
>
|
|
174
|
+
{host.link.label}
|
|
175
|
+
<OpenInNewIcon sx={{ fontSize: 16, ml: 0.5 }} />
|
|
176
|
+
</Button>
|
|
177
|
+
</Box>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A "Para mais informações" reference to the host's official connector docs. */
|
|
182
|
+
export function HostDocsLink({ host }: { host: AiHostGuide }): React.JSX.Element | null {
|
|
183
|
+
if (!host.docs) return null;
|
|
184
|
+
return (
|
|
185
|
+
<Text variant="caption" as="p" color="secondary">
|
|
186
|
+
Para mais informações, consulte a{" "}
|
|
187
|
+
<Box
|
|
188
|
+
component="a"
|
|
189
|
+
href={host.docs.url}
|
|
190
|
+
target="_blank"
|
|
191
|
+
rel="noopener noreferrer"
|
|
192
|
+
data-testid={`ai-host-docs-${host.id}`}
|
|
193
|
+
sx={{ color: "primary.main", textDecoration: "underline" }}
|
|
194
|
+
>
|
|
195
|
+
{host.docs.label}
|
|
196
|
+
</Box>
|
|
197
|
+
.
|
|
198
|
+
</Text>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* A numbered slice of a host's connect steps. `start` keeps the numbering
|
|
204
|
+
* continuous when the steps are split across wizard stages (e.g. the Conectar
|
|
205
|
+
* stage continues at 4 after Configurar showed 1–3).
|
|
206
|
+
*/
|
|
207
|
+
export function HostStepList({
|
|
208
|
+
steps,
|
|
209
|
+
start = 1,
|
|
210
|
+
}: {
|
|
211
|
+
steps: readonly string[];
|
|
212
|
+
start?: number;
|
|
213
|
+
}): React.JSX.Element {
|
|
214
|
+
return (
|
|
215
|
+
<Box component="ol" start={start} sx={{ m: 0, pl: 3 }}>
|
|
216
|
+
{steps.map((step, index) => (
|
|
217
|
+
<Box component="li" key={index} sx={{ mb: 1 }}>
|
|
218
|
+
<Text variant="body" as="span">
|
|
219
|
+
{step}
|
|
220
|
+
</Text>
|
|
221
|
+
</Box>
|
|
222
|
+
))}
|
|
223
|
+
</Box>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
|
4
|
+
|
|
5
|
+
import { Box } from "@12-apps/ui/mui/Box";
|
|
6
|
+
import { Stack } from "@12-apps/ui/mui/Stack";
|
|
7
|
+
import { Text } from "@12-apps/ui/typography/Text";
|
|
8
|
+
|
|
9
|
+
import type { AiHostGuide } from "../guide";
|
|
10
|
+
import { HostBrandAvatar } from "./ai-icons";
|
|
11
|
+
|
|
12
|
+
/** One selectable assistant card (brand mark + name + kind, checked when active). */
|
|
13
|
+
function HostCard({
|
|
14
|
+
host,
|
|
15
|
+
active,
|
|
16
|
+
onSelect,
|
|
17
|
+
}: {
|
|
18
|
+
host: AiHostGuide;
|
|
19
|
+
active: boolean;
|
|
20
|
+
onSelect: () => void;
|
|
21
|
+
}): React.JSX.Element {
|
|
22
|
+
return (
|
|
23
|
+
<Box
|
|
24
|
+
component="button"
|
|
25
|
+
type="button"
|
|
26
|
+
onClick={onSelect}
|
|
27
|
+
aria-pressed={active}
|
|
28
|
+
data-testid={`ai-host-card-${host.id}`}
|
|
29
|
+
sx={{
|
|
30
|
+
position: "relative",
|
|
31
|
+
display: "flex",
|
|
32
|
+
alignItems: "center",
|
|
33
|
+
gap: 1.5,
|
|
34
|
+
p: 2,
|
|
35
|
+
borderRadius: 3,
|
|
36
|
+
cursor: "pointer",
|
|
37
|
+
textAlign: "left",
|
|
38
|
+
width: "100%",
|
|
39
|
+
border: 2,
|
|
40
|
+
borderColor: active ? "primary.main" : "divider",
|
|
41
|
+
bgcolor: active ? "action.selected" : "background.paper",
|
|
42
|
+
transition: (theme) => theme.transitions.create(["border-color", "background-color"]),
|
|
43
|
+
"&:hover": { borderColor: "primary.main" },
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<HostBrandAvatar brand={host.brand} size={40} />
|
|
47
|
+
<Box sx={{ minWidth: 0 }}>
|
|
48
|
+
<Text variant="body" weight="bold" as="span">
|
|
49
|
+
{host.label}
|
|
50
|
+
</Text>
|
|
51
|
+
<Text variant="caption" as="p" color="secondary">
|
|
52
|
+
{host.kind}
|
|
53
|
+
</Text>
|
|
54
|
+
</Box>
|
|
55
|
+
{active && (
|
|
56
|
+
<CheckCircleIcon
|
|
57
|
+
sx={{ position: "absolute", top: 10, right: 10, fontSize: 20, color: "primary.main" }}
|
|
58
|
+
/>
|
|
59
|
+
)}
|
|
60
|
+
</Box>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Step 1 of the guided AI connect flow: the owner picks a single assistant
|
|
66
|
+
* (Claude.ai, Claude Desktop, ChatGPT, Codex) from a card grid. Picking a card
|
|
67
|
+
* advances straight to the next step — no separate "Próximo" button. Selection
|
|
68
|
+
* is controlled (persisted by the onboarding flow) so a refresh resumes with the
|
|
69
|
+
* same assistant highlighted.
|
|
70
|
+
*/
|
|
71
|
+
export function HostSelectStep({
|
|
72
|
+
hosts,
|
|
73
|
+
selectedId,
|
|
74
|
+
onSelect,
|
|
75
|
+
}: {
|
|
76
|
+
hosts: readonly AiHostGuide[];
|
|
77
|
+
selectedId: string | null;
|
|
78
|
+
onSelect: (hostId: string) => void;
|
|
79
|
+
}): React.JSX.Element {
|
|
80
|
+
return (
|
|
81
|
+
<Stack spacing={3} data-testid="ai-host-select">
|
|
82
|
+
<Box>
|
|
83
|
+
<Text variant="heading" size="sm" as="h2">
|
|
84
|
+
Escolha o seu assistente
|
|
85
|
+
</Text>
|
|
86
|
+
<Text variant="caption" as="p" color="secondary">
|
|
87
|
+
Selecione onde você usa a IA — o passo a passo é feito sob medida para ele.
|
|
88
|
+
</Text>
|
|
89
|
+
</Box>
|
|
90
|
+
|
|
91
|
+
<Box
|
|
92
|
+
sx={{
|
|
93
|
+
display: "grid",
|
|
94
|
+
gap: 2,
|
|
95
|
+
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
{hosts.map((host) => (
|
|
99
|
+
<HostCard
|
|
100
|
+
key={host.id}
|
|
101
|
+
host={host}
|
|
102
|
+
active={host.id === selectedId}
|
|
103
|
+
onSelect={() => onSelect(host.id)}
|
|
104
|
+
/>
|
|
105
|
+
))}
|
|
106
|
+
</Box>
|
|
107
|
+
</Stack>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/mcp/react` — the reusable, app-agnostic AI/MCP connect onboarding UI.
|
|
3
|
+
*
|
|
4
|
+
* The app supplies the persistence `store`, the `endpointUrl`, and the live
|
|
5
|
+
* `connection`; the flow's content (hosts, capabilities, copy) defaults to the
|
|
6
|
+
* shared guide and can be overridden per app. React + MUI + `@12-apps/ui` +
|
|
7
|
+
* `@12-apps/onboarding`; server-side MCP core stays on the package root export.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
AiIntegrationOnboarding,
|
|
12
|
+
type AiIntegrationOnboardingProps,
|
|
13
|
+
type AiConnection,
|
|
14
|
+
} from "./ai-onboarding";
|
|
15
|
+
|
|
16
|
+
// Building blocks + content, exported so apps can compose or override.
|
|
17
|
+
export { AiLanding } from "./ai-landing";
|
|
18
|
+
export { AiCapabilities } from "./ai-capabilities";
|
|
19
|
+
export { AiStatusBoard, type HostStatus } from "./ai-status-board";
|
|
20
|
+
export { HostSelectStep } from "./host-select-step";
|
|
21
|
+
export {
|
|
22
|
+
EndpointCopyBlock,
|
|
23
|
+
PromptCopyBlock,
|
|
24
|
+
HostConnectHeader,
|
|
25
|
+
HostOpenButton,
|
|
26
|
+
HostStepList,
|
|
27
|
+
} from "./host-connect-guide";
|
|
28
|
+
export { McpEndpointUrl } from "./mcp-endpoint-url";
|
|
29
|
+
export { HostBrandAvatar, CapabilityIcon } from "./ai-icons";
|
|
30
|
+
export { FeatureBadge, type FeatureBadgeItem } from "./feature-badge";
|
|
31
|
+
export {
|
|
32
|
+
AI_HOST_GUIDES,
|
|
33
|
+
AI_CAPABILITIES,
|
|
34
|
+
AI_PERMISSION_MODEL,
|
|
35
|
+
AI_CONNECT_PROMPT,
|
|
36
|
+
providerForHostId,
|
|
37
|
+
type AiHostBrand,
|
|
38
|
+
type AiHostLink,
|
|
39
|
+
type AiHostConfigureStage,
|
|
40
|
+
type AiHostGuide,
|
|
41
|
+
type AiProvider,
|
|
42
|
+
type AiCapability,
|
|
43
|
+
} from "../guide";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Code } from "@12-apps/ui/typography/Code";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The store's MCP endpoint URL. Client component because the copy interaction
|
|
7
|
+
* needs the browser clipboard — the `@12-apps/ui` Code component provides the copy
|
|
8
|
+
* affordance (`copyable`). Pass `copyable={false}` for a display-only field when
|
|
9
|
+
* an external control owns the copy action (e.g. the connect flow's blue Copiar
|
|
10
|
+
* button, which also gates the following steps on the copy).
|
|
11
|
+
*/
|
|
12
|
+
export function McpEndpointUrl({
|
|
13
|
+
url,
|
|
14
|
+
copyable = true,
|
|
15
|
+
}: {
|
|
16
|
+
url: string;
|
|
17
|
+
copyable?: boolean;
|
|
18
|
+
}): React.JSX.Element {
|
|
19
|
+
return (
|
|
20
|
+
<Code variant="block" copyable={copyable} data-testid="mcp-endpoint-url">
|
|
21
|
+
{url}
|
|
22
|
+
</Code>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -10,6 +10,11 @@ function tool(name: string): GeneratedTool {
|
|
|
10
10
|
method: "GET",
|
|
11
11
|
path: `/${name}`,
|
|
12
12
|
inputSchema: { type: "object", properties: {} },
|
|
13
|
+
annotations: {
|
|
14
|
+
readOnlyHint: true,
|
|
15
|
+
openWorldHint: false,
|
|
16
|
+
destructiveHint: false,
|
|
17
|
+
},
|
|
13
18
|
parameters: [],
|
|
14
19
|
bodyProps: [],
|
|
15
20
|
bodyIsWhole: false,
|
|
@@ -20,11 +25,18 @@ function tool(name: string): GeneratedTool {
|
|
|
20
25
|
|
|
21
26
|
describe("buildManifest", () => {
|
|
22
27
|
it("sorts tools by name regardless of input order", () => {
|
|
23
|
-
const manifest = buildManifest(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
const manifest = buildManifest(
|
|
29
|
+
[tool("charlie"), tool("alpha"), tool("bravo")],
|
|
30
|
+
{
|
|
31
|
+
version: 1,
|
|
32
|
+
source: "test",
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
expect(manifest.tools.map((t) => t.name)).toEqual([
|
|
36
|
+
"alpha",
|
|
37
|
+
"bravo",
|
|
38
|
+
"charlie",
|
|
39
|
+
]);
|
|
28
40
|
expect(manifest.version).toBe(1);
|
|
29
41
|
expect(manifest.source).toBe("test");
|
|
30
42
|
});
|
|
@@ -32,8 +44,12 @@ describe("buildManifest", () => {
|
|
|
32
44
|
|
|
33
45
|
describe("serializeManifest", () => {
|
|
34
46
|
it("is deterministic and ends with a trailing newline", () => {
|
|
35
|
-
const a = serializeManifest(
|
|
36
|
-
|
|
47
|
+
const a = serializeManifest(
|
|
48
|
+
buildManifest([tool("b"), tool("a")], { version: 1, source: "s" }),
|
|
49
|
+
);
|
|
50
|
+
const b = serializeManifest(
|
|
51
|
+
buildManifest([tool("a"), tool("b")], { version: 1, source: "s" }),
|
|
52
|
+
);
|
|
37
53
|
expect(a).toBe(b);
|
|
38
54
|
expect(a.endsWith("\n")).toBe(true);
|
|
39
55
|
});
|
|
@@ -44,8 +60,12 @@ describe("serializeManifest", () => {
|
|
|
44
60
|
// Same data, different key insertion order in inputSchema.
|
|
45
61
|
t1.inputSchema = { type: "object", properties: { b: {}, a: {} } };
|
|
46
62
|
t2.inputSchema = { properties: { a: {}, b: {} }, type: "object" };
|
|
47
|
-
const s1 = serializeManifest(
|
|
48
|
-
|
|
63
|
+
const s1 = serializeManifest(
|
|
64
|
+
buildManifest([t1], { version: 1, source: "s" }),
|
|
65
|
+
);
|
|
66
|
+
const s2 = serializeManifest(
|
|
67
|
+
buildManifest([t2], { version: 1, source: "s" }),
|
|
68
|
+
);
|
|
49
69
|
expect(s1).toBe(s2);
|
|
50
70
|
});
|
|
51
71
|
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { redactResponseBody } from "./redact";
|
|
4
|
+
|
|
5
|
+
describe("redactResponseBody", () => {
|
|
6
|
+
it("returns the body unchanged when nothing is redacted", () => {
|
|
7
|
+
const body = { data: { id: "s1", taxId: "123" } };
|
|
8
|
+
expect(redactResponseBody(body, undefined)).toBe(body);
|
|
9
|
+
expect(redactResponseBody(body, [])).toBe(body);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("strips a nested path without touching its siblings", () => {
|
|
13
|
+
const body = { data: { id: "s1", name: "ACME", taxId: "123" } };
|
|
14
|
+
expect(redactResponseBody(body, ["data.taxId"])).toEqual({
|
|
15
|
+
data: { id: "s1", name: "ACME" },
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("applies a path to every element when it lands on an array", () => {
|
|
20
|
+
const body = {
|
|
21
|
+
data: [
|
|
22
|
+
{ id: "s1", taxId: "1", postalCode: "01000-000" },
|
|
23
|
+
{ id: "s2", taxId: "2", postalCode: "02000-000" },
|
|
24
|
+
],
|
|
25
|
+
pagination: { total: 2 },
|
|
26
|
+
};
|
|
27
|
+
expect(
|
|
28
|
+
redactResponseBody(body, ["data.taxId", "data.postalCode"]),
|
|
29
|
+
).toEqual({ data: [{ id: "s1" }, { id: "s2" }], pagination: { total: 2 } });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("does not mutate the caller's object", () => {
|
|
33
|
+
const body = { data: { id: "s1", taxId: "123" } };
|
|
34
|
+
redactResponseBody(body, ["data.taxId"]);
|
|
35
|
+
expect(body.data.taxId).toBe("123");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("ignores a path that does not exist in the body", () => {
|
|
39
|
+
const body = { data: { id: "s1" } };
|
|
40
|
+
expect(redactResponseBody(body, ["data.location.latitude"])).toEqual(body);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("leaves non-object bodies alone", () => {
|
|
44
|
+
expect(redactResponseBody(null, ["data.taxId"])).toBeNull();
|
|
45
|
+
expect(redactResponseBody("plain", ["data.taxId"])).toBe("plain");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip audited-sensitive fields out of a tool result before it reaches the
|
|
3
|
+
* agent.
|
|
4
|
+
*
|
|
5
|
+
* The dispatcher forwards the wrapped endpoint's response body verbatim, so a
|
|
6
|
+
* narrowed `outputSchema` alone would only change what the manifest CLAIMS is
|
|
7
|
+
* returned. This module is what actually removes the value, keeping the served
|
|
8
|
+
* payload and the advertised schema in agreement.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Walk one dotted path, mapping over arrays, and delete the leaf. */
|
|
12
|
+
function stripPath(value: unknown, segments: readonly string[]): void {
|
|
13
|
+
if (value === null || typeof value !== "object") return;
|
|
14
|
+
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
value.forEach((entry) => stripPath(entry, segments));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const [head, ...rest] = segments;
|
|
21
|
+
if (!head) return;
|
|
22
|
+
|
|
23
|
+
const record = value as Record<string, unknown>;
|
|
24
|
+
if (rest.length === 0) {
|
|
25
|
+
delete record[head];
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (head in record) stripPath(record[head], rest);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Return `body` without the listed dotted paths.
|
|
33
|
+
*
|
|
34
|
+
* The input is deep-cloned first: dispatch results are handed straight to the
|
|
35
|
+
* JSON-RPC encoder AND reused as `structuredContent`, so mutating in place
|
|
36
|
+
* could leak a half-redacted object into one of the two surfaces.
|
|
37
|
+
*/
|
|
38
|
+
export function redactResponseBody(
|
|
39
|
+
body: unknown,
|
|
40
|
+
paths: readonly string[] | undefined,
|
|
41
|
+
): unknown {
|
|
42
|
+
if (!paths?.length || body === null || typeof body !== "object") return body;
|
|
43
|
+
|
|
44
|
+
const clone = structuredClone(body);
|
|
45
|
+
paths.forEach((path) => stripPath(clone, path.split(".")));
|
|
46
|
+
return clone;
|
|
47
|
+
}
|