@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.
@@ -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 = { ...readTool, name: "makeThing", method: "POST", mutating: true };
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({ tools: [readTool], baseUrl: "https://app.example.com" });
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 returns a text result", async () => {
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
  });
@@ -1,5 +1,11 @@
1
1
  import { dispatchTool } from "../dispatch/proxy";
2
- import type { GeneratedTool, JsonSchema, RequestAuth } from "../types";
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(value: unknown, isError: boolean): McpToolResult {
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
- return { content: [{ type: "text", text }], isError };
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) => (options.isVisible ? options.isVisible(tool, auth) : true))
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
- return textResult(result.body, !result.ok);
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). */