@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
|
-
import { createToolRegistry } from "./registry";
|
|
3
|
+
import { createToolRegistry, HTTP_STATUS_META_KEY } from "./registry";
|
|
4
4
|
import type { GeneratedTool, RequestAuth } from "../types";
|
|
5
5
|
|
|
6
6
|
const readTool: GeneratedTool = {
|
|
@@ -9,6 +9,17 @@ const readTool: GeneratedTool = {
|
|
|
9
9
|
method: "GET",
|
|
10
10
|
path: "/things/{id}",
|
|
11
11
|
inputSchema: { type: "object", properties: { id: {} }, required: ["id"] },
|
|
12
|
+
outputSchema: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: { id: { type: "string" } },
|
|
15
|
+
required: ["id"],
|
|
16
|
+
},
|
|
17
|
+
annotations: {
|
|
18
|
+
title: "Fixture tool",
|
|
19
|
+
readOnlyHint: true,
|
|
20
|
+
openWorldHint: false,
|
|
21
|
+
destructiveHint: false,
|
|
22
|
+
},
|
|
12
23
|
parameters: [{ name: "id", in: "path", required: true, schema: {} }],
|
|
13
24
|
bodyProps: [],
|
|
14
25
|
bodyIsWhole: false,
|
|
@@ -16,7 +27,18 @@ const readTool: GeneratedTool = {
|
|
|
16
27
|
security: [],
|
|
17
28
|
};
|
|
18
29
|
|
|
19
|
-
const writeTool: GeneratedTool = {
|
|
30
|
+
const writeTool: GeneratedTool = {
|
|
31
|
+
...readTool,
|
|
32
|
+
name: "makeThing",
|
|
33
|
+
method: "POST",
|
|
34
|
+
mutating: true,
|
|
35
|
+
annotations: {
|
|
36
|
+
title: "Fixture tool",
|
|
37
|
+
readOnlyHint: false,
|
|
38
|
+
openWorldHint: false,
|
|
39
|
+
destructiveHint: false,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
20
42
|
|
|
21
43
|
const auth: RequestAuth = { bearer: "tok" };
|
|
22
44
|
|
|
@@ -36,6 +58,22 @@ function forbiddenFetch(): typeof fetch {
|
|
|
36
58
|
})) as unknown as typeof fetch;
|
|
37
59
|
}
|
|
38
60
|
|
|
61
|
+
/** Answers with an arbitrary non-2xx, for asserting the status reaches `_meta`. */
|
|
62
|
+
function statusFetch(status: number): typeof fetch {
|
|
63
|
+
return (async () =>
|
|
64
|
+
new Response(JSON.stringify({ error: "nope" }), {
|
|
65
|
+
status,
|
|
66
|
+
headers: { "content-type": "application/json" },
|
|
67
|
+
})) as unknown as typeof fetch;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Never reaches the route at all — the dispatch itself throws. */
|
|
71
|
+
function throwingFetch(): typeof fetch {
|
|
72
|
+
return (async () => {
|
|
73
|
+
throw new Error("connect ECONNREFUSED");
|
|
74
|
+
}) as unknown as typeof fetch;
|
|
75
|
+
}
|
|
76
|
+
|
|
39
77
|
describe("createToolRegistry", () => {
|
|
40
78
|
it("lists tools as MCP descriptors and honours the visibility filter", () => {
|
|
41
79
|
const registry = createToolRegistry({
|
|
@@ -46,16 +84,49 @@ describe("createToolRegistry", () => {
|
|
|
46
84
|
const names = registry.listTools().map((t) => t.name);
|
|
47
85
|
expect(names).toEqual(["getThing"]);
|
|
48
86
|
expect(registry.listTools()[0].inputSchema).toEqual(readTool.inputSchema);
|
|
87
|
+
expect(registry.listTools()[0].outputSchema).toEqual(readTool.outputSchema);
|
|
88
|
+
expect(registry.listTools()[0].annotations).toEqual(readTool.annotations);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("strips redacted response fields from both text and structuredContent", async () => {
|
|
92
|
+
const redactingTool: GeneratedTool = {
|
|
93
|
+
...readTool,
|
|
94
|
+
name: "getSupplier",
|
|
95
|
+
redactResponse: ["data.taxId"],
|
|
96
|
+
};
|
|
97
|
+
const registry = createToolRegistry({
|
|
98
|
+
tools: [redactingTool],
|
|
99
|
+
baseUrl: "https://app.example.com",
|
|
100
|
+
fetchImpl: (async () =>
|
|
101
|
+
new Response(
|
|
102
|
+
JSON.stringify({ data: { id: "s1", name: "ACME", taxId: "123" } }),
|
|
103
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
104
|
+
)) as unknown as typeof fetch,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result = await registry.callTool("getSupplier", { id: "s1" }, auth);
|
|
108
|
+
|
|
109
|
+
expect(result.isError).toBe(false);
|
|
110
|
+
// The agent reads the text block even when it ignores structuredContent, so
|
|
111
|
+
// the value must be gone from both surfaces.
|
|
112
|
+
expect(result.content[0]?.text).not.toContain("123");
|
|
113
|
+
expect(result.content[0]?.text).toContain("ACME");
|
|
114
|
+
expect(result.structuredContent).toEqual({
|
|
115
|
+
data: { id: "s1", name: "ACME" },
|
|
116
|
+
});
|
|
49
117
|
});
|
|
50
118
|
|
|
51
119
|
it("returns an error result for an unknown tool", async () => {
|
|
52
|
-
const registry = createToolRegistry({
|
|
120
|
+
const registry = createToolRegistry({
|
|
121
|
+
tools: [readTool],
|
|
122
|
+
baseUrl: "https://app.example.com",
|
|
123
|
+
});
|
|
53
124
|
const result = await registry.callTool("nope", {}, auth);
|
|
54
125
|
expect(result.isError).toBe(true);
|
|
55
126
|
expect(result.content[0].text).toContain("Unknown tool");
|
|
56
127
|
});
|
|
57
128
|
|
|
58
|
-
it("proxies a successful call and
|
|
129
|
+
it("proxies a successful call with text and schema-matching structured content", async () => {
|
|
59
130
|
const registry = createToolRegistry({
|
|
60
131
|
tools: [readTool],
|
|
61
132
|
baseUrl: "https://app.example.com",
|
|
@@ -64,6 +135,7 @@ describe("createToolRegistry", () => {
|
|
|
64
135
|
const result = await registry.callTool("getThing", { id: "1" }, auth);
|
|
65
136
|
expect(result.isError).toBe(false);
|
|
66
137
|
expect(JSON.parse(result.content[0].text)).toEqual({ id: "1" });
|
|
138
|
+
expect(result.structuredContent).toEqual({ id: "1" });
|
|
67
139
|
});
|
|
68
140
|
|
|
69
141
|
it("marks an upstream 403 as an error result (authz decided upstream)", async () => {
|
|
@@ -76,4 +148,41 @@ describe("createToolRegistry", () => {
|
|
|
76
148
|
expect(result.isError).toBe(true);
|
|
77
149
|
expect(result.content[0].text).toContain("forbidden");
|
|
78
150
|
});
|
|
151
|
+
|
|
152
|
+
// `isError` alone cannot separate "correctly refused" from "actually broken":
|
|
153
|
+
// a 404 for a missing record and a 500 from a thrown route are the same bit.
|
|
154
|
+
// Callers that must tell them apart read the status from `_meta`.
|
|
155
|
+
it.each([403, 404, 422, 500])(
|
|
156
|
+
"carries the upstream %i in _meta so callers can classify the failure",
|
|
157
|
+
async (status) => {
|
|
158
|
+
const result = await createToolRegistry({
|
|
159
|
+
tools: [readTool],
|
|
160
|
+
baseUrl: "https://app.example.com",
|
|
161
|
+
fetchImpl: statusFetch(status),
|
|
162
|
+
}).callTool("getThing", { id: "1" }, auth);
|
|
163
|
+
expect(result.isError).toBe(true);
|
|
164
|
+
expect(result._meta?.[HTTP_STATUS_META_KEY]).toBe(status);
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
it("omits the status _meta when dispatch never reached the route", async () => {
|
|
169
|
+
const result = await createToolRegistry({
|
|
170
|
+
tools: [readTool],
|
|
171
|
+
baseUrl: "https://app.example.com",
|
|
172
|
+
fetchImpl: throwingFetch(),
|
|
173
|
+
}).callTool("getThing", { id: "1" }, auth);
|
|
174
|
+
expect(result.isError).toBe(true);
|
|
175
|
+
// No status at all is itself the signal — there was no HTTP answer to bucket.
|
|
176
|
+
expect(result._meta).toBeUndefined();
|
|
177
|
+
expect(result.content[0].text).toContain("Tool dispatch failed");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("adds no _meta to a successful call", async () => {
|
|
181
|
+
const result = await createToolRegistry({
|
|
182
|
+
tools: [readTool],
|
|
183
|
+
baseUrl: "https://app.example.com",
|
|
184
|
+
fetchImpl: okFetch(),
|
|
185
|
+
}).callTool("getThing", { id: "1" }, auth);
|
|
186
|
+
expect(result._meta).toBeUndefined();
|
|
187
|
+
});
|
|
79
188
|
});
|
package/src/server/registry.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { dispatchTool } from "../dispatch/proxy";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
GeneratedTool,
|
|
4
|
+
JsonSchema,
|
|
5
|
+
RequestAuth,
|
|
6
|
+
ToolAnnotations,
|
|
7
|
+
} from "../types";
|
|
8
|
+
import { redactResponseBody } from "./redact";
|
|
3
9
|
|
|
4
10
|
/**
|
|
5
11
|
* The registry is the transport-agnostic seam between the generated tools and the
|
|
@@ -14,12 +20,32 @@ export interface McpToolDescriptor {
|
|
|
14
20
|
name: string;
|
|
15
21
|
description: string;
|
|
16
22
|
inputSchema: JsonSchema;
|
|
23
|
+
outputSchema?: JsonSchema;
|
|
24
|
+
annotations: ToolAnnotations;
|
|
17
25
|
}
|
|
18
26
|
|
|
27
|
+
/**
|
|
28
|
+
* `_meta` key carrying the upstream HTTP status of a dispatched call.
|
|
29
|
+
*
|
|
30
|
+
* `isError` is one bit, and it collapses answers that mean opposite things: a
|
|
31
|
+
* 404 for a record that does not exist, a 403 a guard correctly refused, a
|
|
32
|
+
* domain refusal ("this store does not use comandas"), and a 500 where the route
|
|
33
|
+
* threw all arrive identical. Callers that need to tell "correctly refused" from
|
|
34
|
+
* "actually broken" — `mcp:smoke` above all — cannot, because the status is
|
|
35
|
+
* known at dispatch and then dropped. Publishing it under a namespaced `_meta`
|
|
36
|
+
* key (permitted by the MCP result schema) keeps `isError` as the agent-facing
|
|
37
|
+
* signal while making the distinction recoverable.
|
|
38
|
+
*/
|
|
39
|
+
export const HTTP_STATUS_META_KEY = "dispatch/httpStatus";
|
|
40
|
+
|
|
19
41
|
/** An MCP tool-call result (subset of the MCP schema). */
|
|
20
42
|
export interface McpToolResult {
|
|
21
43
|
content: Array<{ type: "text"; text: string }>;
|
|
22
44
|
isError: boolean;
|
|
45
|
+
/** Machine-readable output matching the advertised outputSchema. */
|
|
46
|
+
structuredContent?: Record<string, unknown>;
|
|
47
|
+
/** Out-of-band metadata; carries {@link HTTP_STATUS_META_KEY} when dispatched. */
|
|
48
|
+
_meta?: Record<string, unknown>;
|
|
23
49
|
}
|
|
24
50
|
|
|
25
51
|
export interface ToolRegistry {
|
|
@@ -44,10 +70,35 @@ export interface RegistryOptions {
|
|
|
44
70
|
isVisible?: (tool: GeneratedTool, auth?: RequestAuth) => boolean;
|
|
45
71
|
}
|
|
46
72
|
|
|
47
|
-
function textResult(
|
|
73
|
+
function textResult(
|
|
74
|
+
value: unknown,
|
|
75
|
+
isError: boolean,
|
|
76
|
+
httpStatus?: number,
|
|
77
|
+
): McpToolResult {
|
|
48
78
|
const text =
|
|
49
79
|
typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
50
|
-
|
|
80
|
+
const result: McpToolResult = { content: [{ type: "text", text }], isError };
|
|
81
|
+
if (httpStatus !== undefined) {
|
|
82
|
+
result._meta = { [HTTP_STATUS_META_KEY]: httpStatus };
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function successfulResult(tool: GeneratedTool, value: unknown): McpToolResult {
|
|
88
|
+
// Redact BEFORE rendering the text block: the agent reads `content` even when
|
|
89
|
+
// it ignores `structuredContent`, so stripping only the latter would still
|
|
90
|
+
// hand over the field.
|
|
91
|
+
const safe = redactResponseBody(value, tool.redactResponse);
|
|
92
|
+
const result = textResult(safe, false);
|
|
93
|
+
if (
|
|
94
|
+
tool.outputSchema &&
|
|
95
|
+
safe !== null &&
|
|
96
|
+
typeof safe === "object" &&
|
|
97
|
+
!Array.isArray(safe)
|
|
98
|
+
) {
|
|
99
|
+
result.structuredContent = safe as Record<string, unknown>;
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
51
102
|
}
|
|
52
103
|
|
|
53
104
|
export function createToolRegistry(options: RegistryOptions): ToolRegistry {
|
|
@@ -56,11 +107,15 @@ export function createToolRegistry(options: RegistryOptions): ToolRegistry {
|
|
|
56
107
|
return {
|
|
57
108
|
listTools(auth) {
|
|
58
109
|
return options.tools
|
|
59
|
-
.filter((tool) =>
|
|
110
|
+
.filter((tool) =>
|
|
111
|
+
options.isVisible ? options.isVisible(tool, auth) : true,
|
|
112
|
+
)
|
|
60
113
|
.map((tool) => ({
|
|
61
114
|
name: tool.name,
|
|
62
115
|
description: tool.description,
|
|
63
116
|
inputSchema: tool.inputSchema,
|
|
117
|
+
...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
|
|
118
|
+
annotations: tool.annotations,
|
|
64
119
|
}));
|
|
65
120
|
},
|
|
66
121
|
|
|
@@ -76,8 +131,11 @@ export function createToolRegistry(options: RegistryOptions): ToolRegistry {
|
|
|
76
131
|
});
|
|
77
132
|
// A non-2xx from the endpoint (e.g. 403 tenant-forbidden) is surfaced to
|
|
78
133
|
// the agent as an error result, NOT thrown — the permission decision was
|
|
79
|
-
// made upstream and its message is the useful signal.
|
|
80
|
-
|
|
134
|
+
// made upstream and its message is the useful signal. The status rides
|
|
135
|
+
// along in `_meta` so a caller can tell a correct refusal from a break.
|
|
136
|
+
return result.ok
|
|
137
|
+
? successfulResult(tool, result.body)
|
|
138
|
+
: textResult(result.body, true, result.status);
|
|
81
139
|
} catch (error) {
|
|
82
140
|
const message = error instanceof Error ? error.message : String(error);
|
|
83
141
|
return textResult(`Tool dispatch failed: ${message}`, true);
|
package/src/types.ts
CHANGED
|
@@ -15,6 +15,23 @@
|
|
|
15
15
|
/** A JSON Schema object (draft 2020-12). We treat schemas opaquely and forward them. */
|
|
16
16
|
export type JsonSchema = Record<string, unknown>;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* MCP tool-behavior annotations used by clients for review and confirmation.
|
|
20
|
+
*
|
|
21
|
+
* Every value is required intentionally. ChatGPT App review treats a missing
|
|
22
|
+
* hint as a blocker, and an implicit protocol default is not enough evidence
|
|
23
|
+
* that a tool's behavior was audited. The Anthropic connector directory
|
|
24
|
+
* additionally requires a human-readable `title` on every tool, and derives
|
|
25
|
+
* auto-permissions from `readOnlyHint`/`destructiveHint`.
|
|
26
|
+
*/
|
|
27
|
+
export interface ToolAnnotations {
|
|
28
|
+
/** Human-readable tool label (required by the Anthropic connector review). */
|
|
29
|
+
title: string;
|
|
30
|
+
readOnlyHint: boolean;
|
|
31
|
+
openWorldHint: boolean;
|
|
32
|
+
destructiveHint: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
/** Where an operation parameter is carried in the HTTP request. */
|
|
19
36
|
export type ParameterLocation = "path" | "query" | "header";
|
|
20
37
|
|
|
@@ -43,6 +60,18 @@ export interface GeneratedTool {
|
|
|
43
60
|
inputSchema: JsonSchema;
|
|
44
61
|
/** Documented success-response schema, when the spec provides one. */
|
|
45
62
|
outputSchema?: JsonSchema;
|
|
63
|
+
/** Explicit, behavior-audited MCP review hints. */
|
|
64
|
+
annotations: ToolAnnotations;
|
|
65
|
+
/**
|
|
66
|
+
* Dotted response paths stripped before the result reaches the agent.
|
|
67
|
+
*
|
|
68
|
+
* `outputSchema` is advertisement only — the dispatcher forwards the upstream
|
|
69
|
+
* body verbatim — so narrowing a schema alone would misdescribe what is
|
|
70
|
+
* actually sent. Redaction is what removes the value; the narrowed schema
|
|
71
|
+
* just keeps the advertisement honest. A segment that lands on an array is
|
|
72
|
+
* applied to every element.
|
|
73
|
+
*/
|
|
74
|
+
redactResponse?: readonly string[];
|
|
46
75
|
/** Path/query/header parameters, in declaration order. */
|
|
47
76
|
parameters: ToolParameter[];
|
|
48
77
|
/** Top-level property names sourced from the request body (routed to the body). */
|