@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
package/package.json
CHANGED
|
@@ -1,17 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
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).",
|
|
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": {
|
|
7
|
-
".": "./src/index.ts"
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./react": "./src/react/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"clean": "rm -rf node_modules coverage",
|
|
12
|
+
"test": "node ../../scripts/vitest-with-teardown.mjs run --passWithNoTests",
|
|
13
|
+
"test:watch": "vitest watch",
|
|
14
|
+
"lint": "eslint src --max-warnings 0",
|
|
15
|
+
"check-types": "tsc --noEmit",
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@mui/icons-material": "^6.5.0",
|
|
20
|
+
"@12-apps/onboarding": "^1.3.0",
|
|
21
|
+
"@12-apps/ui": "^1.3.0",
|
|
22
|
+
"react": "^19.2.0"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"react": ">=19.0.0"
|
|
8
26
|
},
|
|
9
27
|
"devDependencies": {
|
|
28
|
+
"@mui/material": "^6.5.0",
|
|
29
|
+
"@12-apps/typescript-config": "^1.3.0",
|
|
10
30
|
"@types/node": "^22.15.3",
|
|
31
|
+
"@types/react": "19.2.2",
|
|
11
32
|
"eslint": "^9.39.1",
|
|
12
33
|
"typescript": "^5.8.2",
|
|
13
|
-
"vitest": "^3.2.4"
|
|
14
|
-
"@12-apps/typescript-config": "0.0.0"
|
|
34
|
+
"vitest": "^3.2.4"
|
|
15
35
|
},
|
|
16
36
|
"engines": {
|
|
17
37
|
"node": ">=22.0.0"
|
|
@@ -19,7 +39,7 @@
|
|
|
19
39
|
"license": "MIT",
|
|
20
40
|
"publishConfig": {
|
|
21
41
|
"registry": "https://registry.npmjs.org",
|
|
22
|
-
"access": "
|
|
42
|
+
"access": "public"
|
|
23
43
|
},
|
|
24
44
|
"repository": {
|
|
25
45
|
"type": "git",
|
|
@@ -33,13 +53,5 @@
|
|
|
33
53
|
"*.js",
|
|
34
54
|
"*.mjs",
|
|
35
55
|
"*.md"
|
|
36
|
-
]
|
|
37
|
-
|
|
38
|
-
"clean": "rm -rf node_modules coverage",
|
|
39
|
-
"test": "vitest run --passWithNoTests",
|
|
40
|
-
"test:watch": "vitest watch",
|
|
41
|
-
"lint": "eslint src --max-warnings 0",
|
|
42
|
-
"check-types": "tsc --noEmit",
|
|
43
|
-
"typecheck": "tsc --noEmit"
|
|
44
|
-
}
|
|
45
|
-
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
@@ -9,6 +9,12 @@ const getTool: GeneratedTool = {
|
|
|
9
9
|
method: "GET",
|
|
10
10
|
path: "/products/{id}",
|
|
11
11
|
inputSchema: {},
|
|
12
|
+
annotations: {
|
|
13
|
+
title: "Fixture tool",
|
|
14
|
+
readOnlyHint: true,
|
|
15
|
+
openWorldHint: false,
|
|
16
|
+
destructiveHint: false,
|
|
17
|
+
},
|
|
12
18
|
parameters: [
|
|
13
19
|
{ name: "id", in: "path", required: true, schema: {} },
|
|
14
20
|
{ name: "include", in: "query", required: false, schema: {} },
|
|
@@ -26,6 +32,12 @@ const postTool: GeneratedTool = {
|
|
|
26
32
|
method: "POST",
|
|
27
33
|
path: "/products",
|
|
28
34
|
inputSchema: {},
|
|
35
|
+
annotations: {
|
|
36
|
+
title: "Fixture tool",
|
|
37
|
+
readOnlyHint: false,
|
|
38
|
+
openWorldHint: true,
|
|
39
|
+
destructiveHint: false,
|
|
40
|
+
},
|
|
29
41
|
parameters: [],
|
|
30
42
|
bodyProps: ["name", "priceCents"],
|
|
31
43
|
bodyIsWhole: false,
|
|
@@ -38,7 +50,11 @@ interface Captured {
|
|
|
38
50
|
init: RequestInit;
|
|
39
51
|
}
|
|
40
52
|
|
|
41
|
-
function fakeFetch(
|
|
53
|
+
function fakeFetch(
|
|
54
|
+
status: number,
|
|
55
|
+
payload: unknown,
|
|
56
|
+
captured: Captured[],
|
|
57
|
+
): typeof fetch {
|
|
42
58
|
return (async (url: string, init: RequestInit) => {
|
|
43
59
|
captured.push({ url, init });
|
|
44
60
|
return new Response(JSON.stringify(payload), {
|
|
@@ -54,12 +70,18 @@ describe("dispatchTool", () => {
|
|
|
54
70
|
const result = await dispatchTool(
|
|
55
71
|
getTool,
|
|
56
72
|
{ id: "abc 1", include: "variations", "x-trace": "t1" },
|
|
57
|
-
{
|
|
73
|
+
{
|
|
74
|
+
baseUrl: "https://app.example.com",
|
|
75
|
+
bearer: "tok123",
|
|
76
|
+
fetchImpl: fakeFetch(200, { ok: 1 }, captured),
|
|
77
|
+
},
|
|
58
78
|
);
|
|
59
79
|
|
|
60
80
|
expect(captured).toHaveLength(1);
|
|
61
81
|
const { url, init } = captured[0];
|
|
62
|
-
expect(url).toBe(
|
|
82
|
+
expect(url).toBe(
|
|
83
|
+
"https://app.example.com/products/abc%201?include=variations",
|
|
84
|
+
);
|
|
63
85
|
const headers = init.headers as Record<string, string>;
|
|
64
86
|
expect(headers.authorization).toBe("Bearer tok123");
|
|
65
87
|
expect(headers["x-trace"]).toBe("t1");
|
|
@@ -74,17 +96,34 @@ describe("dispatchTool", () => {
|
|
|
74
96
|
await dispatchTool(
|
|
75
97
|
postTool,
|
|
76
98
|
{ name: "Cola", priceCents: 500, sneaky: "drop-me" },
|
|
77
|
-
{
|
|
99
|
+
{
|
|
100
|
+
baseUrl: "https://app.example.com",
|
|
101
|
+
bearer: "t",
|
|
102
|
+
fetchImpl: fakeFetch(201, {}, captured),
|
|
103
|
+
},
|
|
78
104
|
);
|
|
79
105
|
const { init } = captured[0];
|
|
80
106
|
expect(init.method).toBe("POST");
|
|
81
|
-
expect(JSON.parse(init.body as string)).toEqual({
|
|
82
|
-
|
|
107
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
108
|
+
name: "Cola",
|
|
109
|
+
priceCents: 500,
|
|
110
|
+
});
|
|
111
|
+
expect((init.headers as Record<string, string>)["content-type"]).toBe(
|
|
112
|
+
"application/json",
|
|
113
|
+
);
|
|
83
114
|
});
|
|
84
115
|
|
|
85
116
|
it("throws on a missing required path parameter", async () => {
|
|
86
117
|
await expect(
|
|
87
|
-
dispatchTool(
|
|
118
|
+
dispatchTool(
|
|
119
|
+
getTool,
|
|
120
|
+
{},
|
|
121
|
+
{
|
|
122
|
+
baseUrl: "https://app.example.com",
|
|
123
|
+
bearer: "t",
|
|
124
|
+
fetchImpl: fakeFetch(200, {}, []),
|
|
125
|
+
},
|
|
126
|
+
),
|
|
88
127
|
).rejects.toBeInstanceOf(DispatchInputError);
|
|
89
128
|
});
|
|
90
129
|
|
|
@@ -92,7 +131,11 @@ describe("dispatchTool", () => {
|
|
|
92
131
|
const result = await dispatchTool(
|
|
93
132
|
getTool,
|
|
94
133
|
{ id: "1" },
|
|
95
|
-
{
|
|
134
|
+
{
|
|
135
|
+
baseUrl: "https://app.example.com",
|
|
136
|
+
bearer: "t",
|
|
137
|
+
fetchImpl: fakeFetch(403, { error: "forbidden" }, []),
|
|
138
|
+
},
|
|
96
139
|
);
|
|
97
140
|
expect(result.ok).toBe(false);
|
|
98
141
|
expect(result.status).toBe(403);
|
|
@@ -109,7 +152,11 @@ describe("dispatchTool", () => {
|
|
|
109
152
|
await dispatchTool(
|
|
110
153
|
getTool,
|
|
111
154
|
{ id: "1" },
|
|
112
|
-
{
|
|
155
|
+
{
|
|
156
|
+
baseUrl: "http://localhost:4105",
|
|
157
|
+
bearer: "t",
|
|
158
|
+
fetchImpl: fakeFetch(200, {}, captured),
|
|
159
|
+
},
|
|
113
160
|
);
|
|
114
161
|
const headers = captured[0].init.headers as Record<string, string>;
|
|
115
162
|
expect(headers["x-forwarded-proto"]).toBe("http");
|
|
@@ -121,7 +168,11 @@ describe("dispatchTool", () => {
|
|
|
121
168
|
await dispatchTool(
|
|
122
169
|
getTool,
|
|
123
170
|
{ id: "1" },
|
|
124
|
-
{
|
|
171
|
+
{
|
|
172
|
+
baseUrl: "https://menu.example.com",
|
|
173
|
+
bearer: "t",
|
|
174
|
+
fetchImpl: fakeFetch(200, {}, captured),
|
|
175
|
+
},
|
|
125
176
|
);
|
|
126
177
|
const headers = captured[0].init.headers as Record<string, string>;
|
|
127
178
|
expect(headers["x-forwarded-proto"]).toBe("https");
|
package/src/guide.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI-integration guide content — the reusable copy for a store's "Integração com
|
|
3
|
+
* IA" onboarding. Pure, public, non-sensitive text (no React) so the on-page
|
|
4
|
+
* walkthrough stays a portable, testable content module. Host brand marks/icons
|
|
5
|
+
* and colours live in the components. Apps may pass their own overrides to
|
|
6
|
+
* `<AiIntegrationOnboarding>`; these are the shared defaults.
|
|
7
|
+
*
|
|
8
|
+
* The MCP endpoint URL is NOT hardcoded here — the app derives it per request
|
|
9
|
+
* from the deployment origin and passes it in.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Which brand a host belongs to — drives the icon/accent in the UI. */
|
|
13
|
+
export type AiHostBrand = "claude" | "openai";
|
|
14
|
+
|
|
15
|
+
export interface AiHostLink {
|
|
16
|
+
/** Deep-link straight to the host's connector settings, when one exists. */
|
|
17
|
+
url: string;
|
|
18
|
+
label: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One stage of the "configure the connector" part of the flow. A host can split
|
|
23
|
+
* configuration across several stages, each with its OWN deep link, step label
|
|
24
|
+
* and instructions — e.g. ChatGPT needs "enable developer mode" (Security
|
|
25
|
+
* settings) and then "configurar" (create the connector). When a host declares
|
|
26
|
+
* `configureStages`, the wizard renders one wizard step per stage (between
|
|
27
|
+
* "Copiar URL" and "Confirmar") instead of the default Configurar/Conectar
|
|
28
|
+
* split, and the sign-in happens inside a stage — no prompt to paste.
|
|
29
|
+
*/
|
|
30
|
+
export interface AiHostConfigureStage {
|
|
31
|
+
/** Stable id — also the wizard step id + test id suffix (`ai-stage-${id}`). */
|
|
32
|
+
id: string;
|
|
33
|
+
/** Step label shown in the stepper. */
|
|
34
|
+
label: string;
|
|
35
|
+
/** Optional deep link opened on this stage (unlocks "Próximo" once opened). */
|
|
36
|
+
link?: AiHostLink;
|
|
37
|
+
/** The instructions shown for this stage. */
|
|
38
|
+
steps: readonly string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The AI providers a connection can be attributed to — derived server-side from
|
|
43
|
+
* the OAuth client's redirect URIs (and confirmed by the `announceAiConnection`
|
|
44
|
+
* tool). Drives which host card lights up green on the status board.
|
|
45
|
+
*/
|
|
46
|
+
export type AiProvider = "claude" | "chatgpt" | "codex";
|
|
47
|
+
|
|
48
|
+
/** The provider a host id belongs to (claude-desktop shares Claude's provider). */
|
|
49
|
+
export function providerForHostId(hostId: string): AiProvider | null {
|
|
50
|
+
if (hostId === "claude" || hostId === "claude-desktop") return "claude";
|
|
51
|
+
if (hostId === "chatgpt") return "chatgpt";
|
|
52
|
+
if (hostId === "codex") return "codex";
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AiHostGuide {
|
|
57
|
+
/** Stable id — also the panel test id suffix (`ai-setup-${id}`). */
|
|
58
|
+
id: string;
|
|
59
|
+
/** Tab label shown to the owner. */
|
|
60
|
+
label: string;
|
|
61
|
+
brand: AiHostBrand;
|
|
62
|
+
/** One-line hint of what this host is (web app, desktop app, CLI…). */
|
|
63
|
+
kind: string;
|
|
64
|
+
/** Optional direct link button (desktop apps have no deep link). */
|
|
65
|
+
link?: AiHostLink;
|
|
66
|
+
/**
|
|
67
|
+
* Optional link to the host's OFFICIAL connector docs — a stable vendor URL
|
|
68
|
+
* shown as a "Para mais informações" reference (never a deep link with a
|
|
69
|
+
* volatile connector id / UI params).
|
|
70
|
+
*/
|
|
71
|
+
docs?: AiHostLink;
|
|
72
|
+
steps: readonly string[];
|
|
73
|
+
/**
|
|
74
|
+
* Optional per-stage configuration (each with its own deep link + label). When
|
|
75
|
+
* present, the wizard renders one step per stage in place of the default
|
|
76
|
+
* Configurar → Conectar split, and `steps` mirrors the flattened stage
|
|
77
|
+
* instructions so the MCP guide stays a single source of truth.
|
|
78
|
+
*/
|
|
79
|
+
configureStages?: readonly AiHostConfigureStage[];
|
|
80
|
+
/**
|
|
81
|
+
* Optional deep link to a PUBLISHED one-click plugin/connector for this host
|
|
82
|
+
* (set per app via env, e.g. `CHATGPT_PLUGIN_URL` / `CLAUDE_PLUGIN_URL`). When
|
|
83
|
+
* present, the wizard swaps the manual "copy URL + configure connector" path
|
|
84
|
+
* for a simplified "open → Install → authorize" flow: the owner never copies
|
|
85
|
+
* the MCP URL. Absent → the full manual flow.
|
|
86
|
+
*/
|
|
87
|
+
pluginUrl?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const CONNECTOR_TAIL: readonly string[] = [
|
|
91
|
+
"Deixe OAuth Client ID e Client Secret em branco — não é preciso gerar credenciais: a loja registra o conector automaticamente no primeiro acesso.",
|
|
92
|
+
"Confirme e clique em Connect: abre a tela de login da loja — entre com a SUA conta de lojista e autorize o acesso.",
|
|
93
|
+
"Pronto: ative o conector na conversa para o assistente consultar e operar a sua loja.",
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* ChatGPT's two-stage configuration (Developer mode is now required before a
|
|
98
|
+
* connector can be created). Stage 1 enables Developer mode in Security & login;
|
|
99
|
+
* stage 2 creates the connector and signs in — which registers the connection on
|
|
100
|
+
* the store side, so no prompt needs to be pasted afterwards.
|
|
101
|
+
*/
|
|
102
|
+
const CHATGPT_CONFIGURE_STAGES: readonly AiHostConfigureStage[] = [
|
|
103
|
+
{
|
|
104
|
+
id: "enable-dev-mode",
|
|
105
|
+
label: "enable developer mode",
|
|
106
|
+
link: {
|
|
107
|
+
url: "https://chatgpt.com/plugins#settings/Security",
|
|
108
|
+
label: "Abrir Segurança e login",
|
|
109
|
+
},
|
|
110
|
+
steps: [
|
|
111
|
+
"Ative o Modo desenvolvedor em Settings › Security and login (Segurança e login).",
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "configurar",
|
|
116
|
+
label: "configurar",
|
|
117
|
+
link: {
|
|
118
|
+
url: "https://chatgpt.com/plugins#settings/Connectors?create-connector=true&redirectAfter=%2Fplugins",
|
|
119
|
+
label: "Criar o conector",
|
|
120
|
+
},
|
|
121
|
+
steps: [
|
|
122
|
+
"Isso vai abrir um popup para você criar um plugin novo. Coloque como nome o nome da sua loja e, no campo MCP, o link copiado no passo anterior.",
|
|
123
|
+
'Marque a caixa "I understand and want to continue" — a OpenAI não revisou este servidor MCP; ela avisa que sites podem tentar roubar seus dados ou induzir o modelo a ações indevidas, incluindo destruir dados.',
|
|
124
|
+
'Clique em "Sign in with Future Drink" e entre com a sua conta de lojista para autorizar o acesso. Pronto: a conexão é registrada automaticamente.',
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The AI hosts a store owner can connect, in recommended order. Same OAuth flow
|
|
131
|
+
* everywhere (the host drives it) — only the menu path differs per app.
|
|
132
|
+
*/
|
|
133
|
+
export const AI_HOST_GUIDES: readonly AiHostGuide[] = [
|
|
134
|
+
{
|
|
135
|
+
id: "claude",
|
|
136
|
+
label: "Claude.ai",
|
|
137
|
+
brand: "claude",
|
|
138
|
+
kind: "No navegador",
|
|
139
|
+
link: {
|
|
140
|
+
url: "https://claude.ai/new?modal=add-custom-connector#settings/customize-connectors",
|
|
141
|
+
label: "Abrir os conectores do Claude",
|
|
142
|
+
},
|
|
143
|
+
docs: {
|
|
144
|
+
url: "https://support.anthropic.com/en/articles/11175166-how-do-i-connect-mcp-servers-to-claude-ai",
|
|
145
|
+
label: "documentação oficial da Anthropic — conectores personalizados",
|
|
146
|
+
},
|
|
147
|
+
steps: [
|
|
148
|
+
"Clique no botão acima (ou vá em Settings › Customize › Connectors) e escolha Add custom connector.",
|
|
149
|
+
"Dê um nome ao conector (ex.: o nome da sua loja) e cole a URL do servidor MCP da sua loja (copie acima) no campo de URL.",
|
|
150
|
+
...CONNECTOR_TAIL,
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: "claude-desktop",
|
|
155
|
+
label: "Claude Desktop",
|
|
156
|
+
brand: "claude",
|
|
157
|
+
kind: "Aplicativo (Windows/Mac)",
|
|
158
|
+
docs: {
|
|
159
|
+
url: "https://support.anthropic.com/en/articles/11175166-how-do-i-connect-mcp-servers-to-claude-ai",
|
|
160
|
+
label: "documentação oficial da Anthropic — conectores personalizados",
|
|
161
|
+
},
|
|
162
|
+
steps: [
|
|
163
|
+
"Abra o Claude Desktop e vá em Settings (⚙️) › Connectors.",
|
|
164
|
+
"Clique em Add custom connector e cole a URL do servidor MCP da sua loja (copie acima).",
|
|
165
|
+
...CONNECTOR_TAIL,
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: "chatgpt",
|
|
170
|
+
label: "ChatGPT",
|
|
171
|
+
brand: "openai",
|
|
172
|
+
kind: "No navegador",
|
|
173
|
+
link: { url: "https://chatgpt.com/plugins", label: "Abrir os plugins do ChatGPT" },
|
|
174
|
+
docs: {
|
|
175
|
+
url: "https://developers.openai.com/apps-sdk/deploy/connect-chatgpt",
|
|
176
|
+
label: "documentação oficial da OpenAI — conectar um servidor MCP ao ChatGPT",
|
|
177
|
+
},
|
|
178
|
+
configureStages: CHATGPT_CONFIGURE_STAGES,
|
|
179
|
+
// Mirrors the flattened stage instructions so the MCP connect guide
|
|
180
|
+
// (`connectToChatGpt`) can never drift from what owners see in the wizard.
|
|
181
|
+
steps: CHATGPT_CONFIGURE_STAGES.flatMap((stage) => stage.steps),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: "codex",
|
|
185
|
+
label: "Codex",
|
|
186
|
+
brand: "openai",
|
|
187
|
+
kind: "App / CLI de desenvolvedor",
|
|
188
|
+
link: { url: "https://developers.openai.com/codex", label: "Documentação do Codex" },
|
|
189
|
+
docs: {
|
|
190
|
+
url: "https://developers.openai.com/apps-sdk/deploy/connect-chatgpt",
|
|
191
|
+
label: "documentação oficial da OpenAI — conectar um servidor MCP",
|
|
192
|
+
},
|
|
193
|
+
steps: [
|
|
194
|
+
"No Codex, abra as configurações de MCP (Settings › MCP no app, ou o arquivo de configuração na CLI).",
|
|
195
|
+
"Adicione um servidor MCP e cole a URL do servidor MCP da sua loja (copie acima) como um conector remoto (HTTP).",
|
|
196
|
+
...CONNECTOR_TAIL,
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
export interface AiCapability {
|
|
202
|
+
/** Stable id — maps to an icon in the component. */
|
|
203
|
+
id: string;
|
|
204
|
+
/** Card headline. */
|
|
205
|
+
title: string;
|
|
206
|
+
/** Short supporting line, usually an example prompt in quotes. */
|
|
207
|
+
detail: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** What a connected assistant does for the owner — one marketing card each. */
|
|
211
|
+
export const AI_CAPABILITIES: readonly AiCapability[] = [
|
|
212
|
+
{
|
|
213
|
+
id: "orders",
|
|
214
|
+
title: "Acompanhe seus pedidos",
|
|
215
|
+
detail: '"Quais pedidos entraram hoje?"',
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
id: "inventory",
|
|
219
|
+
title: "Controle o estoque",
|
|
220
|
+
detail: '"Quanto ainda tenho do produto X? Registre a entrada de 20 unidades."',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: "catalog",
|
|
224
|
+
title: "Gerencie o catálogo",
|
|
225
|
+
detail: "Crie e edite produtos e categorias conversando.",
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: "sales",
|
|
229
|
+
title: "Entenda suas vendas",
|
|
230
|
+
detail: '"Qual foi o faturamento da semana?"',
|
|
231
|
+
},
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The permission model in one line, shown prominently: the assistant acts AS
|
|
236
|
+
* the signed-in owner (auth-passthrough) — it can do exactly what the owner
|
|
237
|
+
* can, nothing more, and no extra credential/API key is ever created.
|
|
238
|
+
*/
|
|
239
|
+
export const AI_PERMISSION_MODEL =
|
|
240
|
+
"O assistente age em seu nome, com exatamente as suas permissões: ele pode fazer o que você pode fazer na sua loja — nada além disso. Não é preciso criar nenhuma chave ou credencial extra; a autorização usa o seu próprio login.";
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The message the owner pastes into the assistant's chat right after connecting.
|
|
244
|
+
* It makes the assistant (1) call `announceAiConnection` reporting which host it
|
|
245
|
+
* is (chatgpt / claude / codex) — the tool that registers the connection on the
|
|
246
|
+
* server side so the store learns which assistant connected — and (2) call a
|
|
247
|
+
* real read tool to confirm access. The owner types nothing.
|
|
248
|
+
*/
|
|
249
|
+
export const AI_CONNECT_PROMPT =
|
|
250
|
+
"Você agora tem acesso ao conector MCP da minha loja. Faça, nesta ordem:\n" +
|
|
251
|
+
'1) Execute a ferramenta announceAiConnection informando qual assistente você é (host: "chatgpt", "claude" ou "codex") para registrar a conexão com a minha loja.\n' +
|
|
252
|
+
"2) Execute a ferramenta listInventory para confirmar o acesso ao estoque da minha loja.\n" +
|
|
253
|
+
"Se precisar do identificador da loja, me pergunte o tenantSlug.";
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,22 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
export * from "./types";
|
|
12
|
+
// Shared AI-connect guide content (pure data, no React) — the single source of
|
|
13
|
+
// truth for BOTH the `@12-apps/mcp/react` onboarding UI and the server-side connect
|
|
14
|
+
// tools, so what an agent reads via MCP cannot drift from what owners see.
|
|
15
|
+
export {
|
|
16
|
+
AI_HOST_GUIDES,
|
|
17
|
+
AI_CAPABILITIES,
|
|
18
|
+
AI_PERMISSION_MODEL,
|
|
19
|
+
AI_CONNECT_PROMPT,
|
|
20
|
+
providerForHostId,
|
|
21
|
+
type AiHostBrand,
|
|
22
|
+
type AiHostLink,
|
|
23
|
+
type AiHostConfigureStage,
|
|
24
|
+
type AiHostGuide,
|
|
25
|
+
type AiProvider,
|
|
26
|
+
type AiCapability,
|
|
27
|
+
} from "./guide";
|
|
12
28
|
export { generateTools } from "./openapi/generate";
|
|
13
29
|
export { inlineSchemaRefs, UnsupportedSchemaError } from "./openapi/refs";
|
|
14
30
|
export type {
|
|
@@ -21,6 +37,7 @@ export type {
|
|
|
21
37
|
export { dispatchTool, DispatchInputError } from "./dispatch/proxy";
|
|
22
38
|
export {
|
|
23
39
|
createToolRegistry,
|
|
40
|
+
HTTP_STATUS_META_KEY,
|
|
24
41
|
type ToolRegistry,
|
|
25
42
|
type RegistryOptions,
|
|
26
43
|
type McpToolDescriptor,
|
|
@@ -8,12 +8,30 @@ const doc: OpenApiDocument = {
|
|
|
8
8
|
get: {
|
|
9
9
|
operationId: "getProduct",
|
|
10
10
|
summary: "Fetch a product",
|
|
11
|
+
"x-mcp-tool-annotations": {
|
|
12
|
+
title: "Fixture tool",
|
|
13
|
+
readOnlyHint: true,
|
|
14
|
+
openWorldHint: false,
|
|
15
|
+
destructiveHint: false,
|
|
16
|
+
},
|
|
11
17
|
parameters: [
|
|
12
|
-
{
|
|
13
|
-
|
|
18
|
+
{
|
|
19
|
+
name: "id",
|
|
20
|
+
in: "path",
|
|
21
|
+
required: true,
|
|
22
|
+
schema: { type: "string" },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "include",
|
|
26
|
+
in: "query",
|
|
27
|
+
required: false,
|
|
28
|
+
schema: { type: "string" },
|
|
29
|
+
},
|
|
14
30
|
],
|
|
15
31
|
responses: {
|
|
16
|
-
"200": {
|
|
32
|
+
"200": {
|
|
33
|
+
content: { "application/json": { schema: { type: "object" } } },
|
|
34
|
+
},
|
|
17
35
|
},
|
|
18
36
|
security: [{ bearerAuth: [] }],
|
|
19
37
|
},
|
|
@@ -21,6 +39,12 @@ const doc: OpenApiDocument = {
|
|
|
21
39
|
"/products": {
|
|
22
40
|
post: {
|
|
23
41
|
summary: "Create a product",
|
|
42
|
+
"x-mcp-tool-annotations": {
|
|
43
|
+
title: "Fixture tool",
|
|
44
|
+
readOnlyHint: false,
|
|
45
|
+
openWorldHint: true,
|
|
46
|
+
destructiveHint: false,
|
|
47
|
+
},
|
|
24
48
|
requestBody: {
|
|
25
49
|
required: true,
|
|
26
50
|
content: {
|
|
@@ -40,6 +64,12 @@ const doc: OpenApiDocument = {
|
|
|
40
64
|
get: {
|
|
41
65
|
operationId: "listProducts",
|
|
42
66
|
tags: ["internal"],
|
|
67
|
+
"x-mcp-tool-annotations": {
|
|
68
|
+
title: "Fixture tool",
|
|
69
|
+
readOnlyHint: true,
|
|
70
|
+
openWorldHint: false,
|
|
71
|
+
destructiveHint: false,
|
|
72
|
+
},
|
|
43
73
|
},
|
|
44
74
|
},
|
|
45
75
|
},
|
|
@@ -55,31 +85,52 @@ describe("generateTools", () => {
|
|
|
55
85
|
expect(get?.mutating).toBe(false);
|
|
56
86
|
expect(get?.description).toBe("Fetch a product");
|
|
57
87
|
expect(get?.security).toEqual(["bearerAuth"]);
|
|
88
|
+
expect(get?.annotations).toEqual({
|
|
89
|
+
title: "Fixture tool",
|
|
90
|
+
readOnlyHint: true,
|
|
91
|
+
openWorldHint: false,
|
|
92
|
+
destructiveHint: false,
|
|
93
|
+
});
|
|
58
94
|
// path param is required even though only query is marked optional
|
|
59
95
|
expect(get?.inputSchema.required).toEqual(["id"]);
|
|
60
|
-
expect(Object.keys(get?.inputSchema.properties as object)).toEqual([
|
|
61
|
-
|
|
96
|
+
expect(Object.keys(get?.inputSchema.properties as object)).toEqual([
|
|
97
|
+
"id",
|
|
98
|
+
"include",
|
|
99
|
+
]);
|
|
100
|
+
expect(get?.parameters.map((p) => `${p.in}:${p.name}`)).toEqual([
|
|
101
|
+
"path:id",
|
|
102
|
+
"query:include",
|
|
103
|
+
]);
|
|
62
104
|
expect(get?.bodyProps).toEqual([]);
|
|
63
105
|
expect(get?.outputSchema).toEqual({ type: "object" });
|
|
64
106
|
});
|
|
65
107
|
|
|
66
108
|
it("flattens an object request body and records bodyProps + required", () => {
|
|
67
|
-
const post = generateTools(doc).find(
|
|
109
|
+
const post = generateTools(doc).find(
|
|
110
|
+
(t) => t.path === "/products" && t.method === "POST",
|
|
111
|
+
);
|
|
68
112
|
expect(post?.mutating).toBe(true);
|
|
69
113
|
expect(post?.name).toBe("post_products"); // no operationId -> slug
|
|
70
114
|
expect(post?.bodyProps).toEqual(["name", "priceCents"]);
|
|
71
115
|
expect(post?.bodyIsWhole).toBe(false);
|
|
72
116
|
expect(post?.inputSchema.required).toEqual(["name"]);
|
|
73
|
-
expect(Object.keys(post?.inputSchema.properties as object)).toEqual([
|
|
117
|
+
expect(Object.keys(post?.inputSchema.properties as object)).toEqual([
|
|
118
|
+
"name",
|
|
119
|
+
"priceCents",
|
|
120
|
+
]);
|
|
74
121
|
});
|
|
75
122
|
|
|
76
123
|
it("excludes operations by tag", () => {
|
|
77
|
-
const names = generateTools(doc, { excludeTags: ["internal"] }).map(
|
|
124
|
+
const names = generateTools(doc, { excludeTags: ["internal"] }).map(
|
|
125
|
+
(t) => t.name,
|
|
126
|
+
);
|
|
78
127
|
expect(names).not.toContain("listProducts");
|
|
79
128
|
});
|
|
80
129
|
|
|
81
130
|
it("filters by method", () => {
|
|
82
|
-
const methods = generateTools(doc, { includeMethods: ["get"] }).map(
|
|
131
|
+
const methods = generateTools(doc, { includeMethods: ["get"] }).map(
|
|
132
|
+
(t) => t.method,
|
|
133
|
+
);
|
|
83
134
|
expect(new Set(methods)).toEqual(new Set(["GET"]));
|
|
84
135
|
});
|
|
85
136
|
|
|
@@ -93,7 +144,16 @@ describe("generateTools", () => {
|
|
|
93
144
|
"/raw": {
|
|
94
145
|
post: {
|
|
95
146
|
operationId: "postRaw",
|
|
96
|
-
|
|
147
|
+
"x-mcp-tool-annotations": {
|
|
148
|
+
title: "Fixture tool",
|
|
149
|
+
readOnlyHint: false,
|
|
150
|
+
openWorldHint: false,
|
|
151
|
+
destructiveHint: false,
|
|
152
|
+
},
|
|
153
|
+
requestBody: {
|
|
154
|
+
required: true,
|
|
155
|
+
content: { "application/json": { schema: { type: "array" } } },
|
|
156
|
+
},
|
|
97
157
|
},
|
|
98
158
|
},
|
|
99
159
|
},
|
|
@@ -102,6 +162,58 @@ describe("generateTools", () => {
|
|
|
102
162
|
expect(tool.bodyIsWhole).toBe(true);
|
|
103
163
|
expect(tool.bodyProps).toEqual([]);
|
|
104
164
|
expect(tool.inputSchema.required).toEqual(["body"]);
|
|
105
|
-
expect(
|
|
165
|
+
expect(
|
|
166
|
+
(tool.inputSchema.properties as Record<string, unknown>).body,
|
|
167
|
+
).toEqual({ type: "array" });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("rejects operations whose review hints were not explicitly audited", () => {
|
|
171
|
+
const unaudited: OpenApiDocument = {
|
|
172
|
+
paths: { "/unsafe-default": { get: { operationId: "unsafeDefault" } } },
|
|
173
|
+
};
|
|
174
|
+
expect(() => generateTools(unaudited)).toThrow(
|
|
175
|
+
/must explicitly set readOnlyHint, openWorldHint, and destructiveHint/,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("rejects an operation with no human-readable annotations title", () => {
|
|
180
|
+
const untitled: OpenApiDocument = {
|
|
181
|
+
paths: {
|
|
182
|
+
"/untitled": {
|
|
183
|
+
get: {
|
|
184
|
+
operationId: "untitled",
|
|
185
|
+
"x-mcp-tool-annotations": {
|
|
186
|
+
readOnlyHint: true,
|
|
187
|
+
openWorldHint: false,
|
|
188
|
+
destructiveHint: false,
|
|
189
|
+
} as never,
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
expect(() => generateTools(untitled)).toThrow(
|
|
195
|
+
/must set a human-readable annotations.title/,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("carries the response-redaction list onto the generated tool", () => {
|
|
200
|
+
const redacting: OpenApiDocument = {
|
|
201
|
+
paths: {
|
|
202
|
+
"/suppliers": {
|
|
203
|
+
get: {
|
|
204
|
+
operationId: "listSuppliers",
|
|
205
|
+
"x-mcp-tool-annotations": {
|
|
206
|
+
title: "List suppliers",
|
|
207
|
+
readOnlyHint: true,
|
|
208
|
+
openWorldHint: false,
|
|
209
|
+
destructiveHint: false,
|
|
210
|
+
},
|
|
211
|
+
"x-mcp-redact-response": ["data.taxId"],
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
const [tool] = generateTools(redacting);
|
|
217
|
+
expect(tool?.redactResponse).toEqual(["data.taxId"]);
|
|
106
218
|
});
|
|
107
219
|
});
|