@12-apps/mcp 1.15.1 → 1.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/mcp",
3
- "version": "1.15.1",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "description": "App-agnostic MCP server core: generate one MCP tool per OpenAPI operation and proxy each call to the endpoint carrying the caller's bearer token (permission passthrough). Also ships the reusable AI-connect onboarding UI at @12-apps/mcp/react.",
6
6
  "exports": {
@@ -17,8 +17,8 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@mui/icons-material": "^6.5.0",
20
- "@12-apps/onboarding": "^1.15.1",
21
- "@12-apps/ui": "^1.15.1",
20
+ "@12-apps/onboarding": "^1.16.0",
21
+ "@12-apps/ui": "^1.16.0",
22
22
  "react": "^19.2.0"
23
23
  },
24
24
  "peerDependencies": {
@@ -26,10 +26,13 @@
26
26
  },
27
27
  "devDependencies": {
28
28
  "@mui/material": "^6.5.0",
29
- "@12-apps/typescript-config": "^1.16.2",
29
+ "@testing-library/react": "^16.1.0",
30
+ "@12-apps/typescript-config": "^1.17.0",
30
31
  "@types/node": "^22.15.3",
31
32
  "@types/react": "19.2.2",
32
33
  "eslint": "^9.39.1",
34
+ "jsdom": "^25.0.1",
35
+ "react-dom": "^19.2.0",
33
36
  "typescript": "^5.8.2",
34
37
  "vitest": "^3.2.4"
35
38
  },
@@ -17,6 +17,7 @@ import {
17
17
  type AiHostGuide,
18
18
  } from "../guide";
19
19
  import { AiLanding } from "./ai-landing";
20
+ import type { DisconnectHandler } from "./ai-status-board";
20
21
  import {
21
22
  buildFlowSteps,
22
23
  connectedSummary,
@@ -57,6 +58,14 @@ export interface AiIntegrationOnboardingProps {
57
58
  * `window.location.reload()`.
58
59
  */
59
60
  onRetest?: () => void;
61
+ /**
62
+ * Revoke a connected assistant's access, from the completed status board. The
63
+ * host ID is the board's card, NOT the connection — `claude` and
64
+ * `claude-desktop` share one provider, so the app resolves which stored
65
+ * connection it owns (`providerForHostId`) and revokes that. Omitted → the
66
+ * board stays read-only, which is the pre-existing behavior.
67
+ */
68
+ onDisconnect?: DisconnectHandler;
60
69
  }
61
70
 
62
71
  /** Resolved (defaults-applied) props threaded to the in-provider flow body. */
@@ -68,6 +77,7 @@ interface FlowProps {
68
77
  permissionModel: string;
69
78
  connectPrompt: string;
70
79
  onRetest: () => void;
80
+ onDisconnect: DisconnectHandler | undefined;
71
81
  devReset: boolean;
72
82
  }
73
83
 
@@ -78,8 +88,17 @@ interface FlowProps {
78
88
  * Escolher → Copiar URL → Configurar → Conectar → Confirmar flow.
79
89
  */
80
90
  function AiOnboardingFlow(props: FlowProps): React.JSX.Element {
81
- const { endpointUrl, connections, hosts, capabilities, permissionModel, connectPrompt, onRetest, devReset } =
82
- props;
91
+ const {
92
+ endpointUrl,
93
+ connections,
94
+ hosts,
95
+ capabilities,
96
+ permissionModel,
97
+ connectPrompt,
98
+ onRetest,
99
+ onDisconnect,
100
+ devReset,
101
+ } = props;
83
102
  const { state } = useOnboarding();
84
103
  const selectedHost = hosts.find((h) => h.id === state.data.selectedHost) ?? hosts[0]!;
85
104
 
@@ -97,7 +116,9 @@ function AiOnboardingFlow(props: FlowProps): React.JSX.Element {
97
116
  configuredTitle={connectedTitle(connections, hosts, connectedHostId)}
98
117
  configuredSummary={connectedSummary(connections)}
99
118
  editLabel="Conectar IA"
100
- completedContent={(nav) => <StatusBoard nav={nav} connections={connections} hosts={hosts} />}
119
+ completedContent={(nav) => (
120
+ <StatusBoard nav={nav} connections={connections} hosts={hosts} onDisconnect={onDisconnect} />
121
+ )}
101
122
  devReset={devReset}
102
123
  dataTestId="ai-onboarding"
103
124
  />
@@ -129,6 +150,7 @@ export function AiIntegrationOnboarding({
129
150
  onRetest = () => {
130
151
  if (typeof window !== "undefined") window.location.reload();
131
152
  },
153
+ onDisconnect,
132
154
  }: AiIntegrationOnboardingProps): React.JSX.Element {
133
155
  return (
134
156
  <OnboardingProvider featureKey={featureKey} store={store} initialState={initialState}>
@@ -140,6 +162,7 @@ export function AiIntegrationOnboarding({
140
162
  permissionModel={permissionModel}
141
163
  connectPrompt={connectPrompt}
142
164
  onRetest={onRetest}
165
+ onDisconnect={onDisconnect}
143
166
  devReset={devReset}
144
167
  />
145
168
  </OnboardingProvider>
@@ -2,7 +2,10 @@
2
2
 
3
3
  import AddLinkOutlinedIcon from "@mui/icons-material/AddLinkOutlined";
4
4
  import CheckCircleIcon from "@mui/icons-material/CheckCircle";
5
+ import LinkOffOutlinedIcon from "@mui/icons-material/LinkOffOutlined";
6
+ import MenuBookOutlinedIcon from "@mui/icons-material/MenuBookOutlined";
5
7
 
8
+ import { ConfirmButton } from "@12-apps/ui/feedback/ConfirmAction";
6
9
  import { Button } from "@12-apps/ui/form/Button";
7
10
  import { Box } from "@12-apps/ui/mui/Box";
8
11
  import { Stack } from "@12-apps/ui/mui/Stack";
@@ -11,6 +14,13 @@ import { Text } from "@12-apps/ui/typography/Text";
11
14
  import type { AiHostGuide } from "../guide";
12
15
  import { HostBrandAvatar } from "./ai-icons";
13
16
 
17
+ /**
18
+ * Revoke a host's access. Returning a promise holds the confirmation popup in
19
+ * its pending state until the write settles, and a rejection keeps it open
20
+ * carrying the reason — so the owner sees the disconnect finish or fail.
21
+ */
22
+ export type DisconnectHandler = (hostId: string) => void | Promise<unknown>;
23
+
14
24
  /** One assistant's connection state. */
15
25
  export interface HostStatus {
16
26
  host: AiHostGuide;
@@ -19,13 +29,92 @@ export interface HostStatus {
19
29
  detail?: string;
20
30
  }
21
31
 
32
+ /**
33
+ * The right-hand side of a CONNECTED box: the green pill, then the two things
34
+ * the pill alone left the owner unable to do.
35
+ *
36
+ * "Instruções" exists because the setup steps were reachable ONLY from a RED
37
+ * card's "Conectar" — so on an all-green board there was no way to re-read how
38
+ * any of them was connected, which is exactly when someone re-doing the setup on
39
+ * a second machine needs them. It re-enters the same flow that button does.
40
+ *
41
+ * "Desconectar" is only drawn when the app supplies a revoke path: a board with
42
+ * nowhere to send the revoke must not offer a button that does nothing.
43
+ */
44
+ function ConnectedControls({
45
+ host,
46
+ onConnect,
47
+ onDisconnect,
48
+ }: {
49
+ host: AiHostGuide;
50
+ onConnect: (hostId: string) => void;
51
+ onDisconnect?: DisconnectHandler;
52
+ }): React.JSX.Element {
53
+ return (
54
+ <Stack
55
+ direction="row"
56
+ spacing={1}
57
+ alignItems="center"
58
+ justifyContent="flex-end"
59
+ // Wraps rather than squeezing: three controls do not fit a half-width
60
+ // card at every breakpoint.
61
+ sx={{ flexShrink: 0, flexWrap: "wrap", rowGap: 0.5 }}
62
+ >
63
+ <Box
64
+ sx={{
65
+ px: 1,
66
+ py: 0.25,
67
+ borderRadius: 999,
68
+ bgcolor: "success.main",
69
+ color: "success.contrastText",
70
+ }}
71
+ >
72
+ <Text variant="caption" weight="bold" as="span">
73
+ Conectado
74
+ </Text>
75
+ </Box>
76
+ <Button
77
+ variant="text"
78
+ size="sm"
79
+ onClick={() => onConnect(host.id)}
80
+ data-testid={`ai-status-instructions-${host.id}`}
81
+ >
82
+ <MenuBookOutlinedIcon sx={{ fontSize: 16, mr: 0.5 }} />
83
+ Instruções
84
+ </Button>
85
+ {onDisconnect ? (
86
+ <ConfirmButton
87
+ variant="outline"
88
+ color="danger"
89
+ size="sm"
90
+ onClick={() => onDisconnect(host.id)}
91
+ confirm={{
92
+ title: "Desconectar o assistente?",
93
+ entityName: host.label,
94
+ description:
95
+ "Ele perde o acesso à sua loja na hora. Para voltar a usar, será preciso conectar de novo.",
96
+ confirmText: "Desconectar",
97
+ dataTestId: `ai-status-disconnect-confirm-${host.id}`,
98
+ }}
99
+ data-testid={`ai-status-disconnect-${host.id}`}
100
+ >
101
+ <LinkOffOutlinedIcon sx={{ fontSize: 16, mr: 0.5 }} />
102
+ Desconectar
103
+ </ConfirmButton>
104
+ ) : null}
105
+ </Stack>
106
+ );
107
+ }
108
+
22
109
  /** A single green (connected) / red (to connect) status box for one assistant. */
23
110
  function StatusBox({
24
111
  status,
25
112
  onConnect,
113
+ onDisconnect,
26
114
  }: {
27
115
  status: HostStatus;
28
116
  onConnect: (hostId: string) => void;
117
+ onDisconnect?: DisconnectHandler;
29
118
  }): React.JSX.Element {
30
119
  const { host, connected, detail } = status;
31
120
  return (
@@ -56,19 +145,7 @@ function StatusBox({
56
145
  </Text>
57
146
  </Box>
58
147
  {connected ? (
59
- <Box
60
- sx={{
61
- px: 1,
62
- py: 0.25,
63
- borderRadius: 999,
64
- bgcolor: "success.main",
65
- color: "success.contrastText",
66
- }}
67
- >
68
- <Text variant="caption" weight="bold" as="span">
69
- Conectado
70
- </Text>
71
- </Box>
148
+ <ConnectedControls host={host} onConnect={onConnect} onDisconnect={onDisconnect} />
72
149
  ) : (
73
150
  <Button
74
151
  variant="outline"
@@ -90,13 +167,22 @@ function StatusBox({
90
167
  * button that re-enters the guided flow for that host). Each connection is
91
168
  * attributed to its provider (derived from the OAuth client + confirmed by
92
169
  * `announceAiConnection`), so several assistants can show connected at once.
170
+ *
171
+ * A connected box carries two more controls, because the pill it used to show
172
+ * alone was a dead end in both directions: "Instruções" re-enters the host's
173
+ * setup steps (previously reachable only from a RED card, so an all-green board
174
+ * hid them entirely), and "Desconectar" — when the app passes `onDisconnect` —
175
+ * revokes access, which nothing in the UI could do at all.
93
176
  */
94
177
  export function AiStatusBoard({
95
178
  statuses,
96
179
  onConnect,
180
+ onDisconnect,
97
181
  }: {
98
182
  statuses: readonly HostStatus[];
99
183
  onConnect: (hostId: string) => void;
184
+ /** Revoke this host's access. Omit to render a read-only board. */
185
+ onDisconnect?: DisconnectHandler;
100
186
  }): React.JSX.Element {
101
187
  return (
102
188
  <Stack spacing={2} data-testid="ai-status-board">
@@ -116,7 +202,12 @@ export function AiStatusBoard({
116
202
  }}
117
203
  >
118
204
  {statuses.map((status) => (
119
- <StatusBox key={status.host.id} status={status} onConnect={onConnect} />
205
+ <StatusBox
206
+ key={status.host.id}
207
+ status={status}
208
+ onConnect={onConnect}
209
+ onDisconnect={onDisconnect}
210
+ />
120
211
  ))}
121
212
  </Box>
122
213
  </Stack>
@@ -12,7 +12,7 @@ import {
12
12
  CopyUrlStep,
13
13
  InstallStep,
14
14
  } from "./ai-flow-steps";
15
- import { AiStatusBoard, type HostStatus } from "./ai-status-board";
15
+ import { AiStatusBoard, type DisconnectHandler, type HostStatus } from "./ai-status-board";
16
16
  import { HostSelectStep } from "./host-select-step";
17
17
 
18
18
  export type { AiConnection } from "./ai-connection-utils";
@@ -23,10 +23,12 @@ export function StatusBoard({
23
23
  nav,
24
24
  connections,
25
25
  hosts,
26
+ onDisconnect,
26
27
  }: {
27
28
  nav: GuidedNav;
28
29
  connections: readonly AiConnection[];
29
30
  hosts: readonly AiHostGuide[];
31
+ onDisconnect?: DisconnectHandler;
30
32
  }): React.JSX.Element {
31
33
  const legacyHostId = (nav.data.connectedHost ?? nav.data.selectedHost) as string | undefined;
32
34
 
@@ -45,15 +47,38 @@ export function StatusBoard({
45
47
  };
46
48
  });
47
49
 
48
- return (
49
- <AiStatusBoard
50
- statuses={statuses}
51
- onConnect={(hostId) =>
52
- nav.goTo(hosts.find((h) => h.id === hostId)?.pluginUrl ? "install" : "copy", {
53
- selectedHost: hostId,
54
- })
50
+ /** Re-enter this host's setup — the manual copy path, or its one-click install. */
51
+ const goToConnect = (hostId: string): void => {
52
+ nav.goTo(hosts.find((h) => h.id === hostId)?.pluginUrl ? "install" : "copy", {
53
+ selectedHost: hostId,
54
+ });
55
+ };
56
+
57
+ /**
58
+ * Disconnecting the LAST assistant leaves the section configured with nothing
59
+ * configured: a collapsed "IA conectada" summary hiding an all-red board, with
60
+ * the setup instructions two clicks away. So when the revoke empties the
61
+ * board, walk straight back into that host's instructions — the owner who just
62
+ * cut an assistant off is the likeliest person to reconnect one, and the steps
63
+ * are the only thing left worth showing.
64
+ *
65
+ * Cutting one of SEVERAL leaves the board meaningful, so it stays put.
66
+ * `statuses` is the pre-revoke picture, which is what makes "was that the last
67
+ * one?" answerable here without waiting for the app to re-read.
68
+ */
69
+ const disconnect: DisconnectHandler | undefined = onDisconnect
70
+ ? async (hostId) => {
71
+ await onDisconnect(hostId);
72
+ const revoked = providerForHostId(hostId);
73
+ const othersLive = statuses.some(
74
+ (status) => status.connected && providerForHostId(status.host.id) !== revoked,
75
+ );
76
+ if (!othersLive) goToConnect(hostId);
55
77
  }
56
- />
78
+ : undefined;
79
+
80
+ return (
81
+ <AiStatusBoard statuses={statuses} onConnect={goToConnect} onDisconnect={disconnect} />
57
82
  );
58
83
  }
59
84
 
@@ -16,7 +16,7 @@ export {
16
16
  // Building blocks + content, exported so apps can compose or override.
17
17
  export { AiLanding } from "./ai-landing";
18
18
  export { AiCapabilities } from "./ai-capabilities";
19
- export { AiStatusBoard, type HostStatus } from "./ai-status-board";
19
+ export { AiStatusBoard, type DisconnectHandler, type HostStatus } from "./ai-status-board";
20
20
  export { HostSelectStep } from "./host-select-step";
21
21
  export {
22
22
  EndpointCopyBlock,