@12-apps/mcp 1.5.0 → 1.5.2
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 +13 -5
- package/src/auth/authorization-server-metadata.test.ts +0 -71
- package/src/auth/resource-metadata.test.ts +0 -46
- package/src/dispatch/proxy.test.ts +0 -181
- package/src/openapi/generate.test.ts +0 -219
- package/src/openapi/refs.test.ts +0 -80
- package/src/server/manifest.test.ts +0 -71
- package/src/server/redact.test.ts +0 -47
- package/src/server/registry.test.ts +0 -188
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/mcp",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.2",
|
|
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.5.
|
|
21
|
-
"@12-apps/ui": "^1.5.
|
|
20
|
+
"@12-apps/onboarding": "^1.5.2",
|
|
21
|
+
"@12-apps/ui": "^1.5.2",
|
|
22
22
|
"react": "^19.2.0"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@mui/material": "^6.5.0",
|
|
29
|
-
"@12-apps/typescript-config": "^1.5.
|
|
29
|
+
"@12-apps/typescript-config": "^1.5.3",
|
|
30
30
|
"@types/node": "^22.15.3",
|
|
31
31
|
"@types/react": "19.2.2",
|
|
32
32
|
"eslint": "^9.39.1",
|
|
@@ -52,6 +52,14 @@
|
|
|
52
52
|
"prisma",
|
|
53
53
|
"*.js",
|
|
54
54
|
"*.mjs",
|
|
55
|
-
"*.md"
|
|
55
|
+
"*.md",
|
|
56
|
+
"!eslint.config.js",
|
|
57
|
+
"!**/__tests__/**",
|
|
58
|
+
"!**/tests/**",
|
|
59
|
+
"!**/*.test.*",
|
|
60
|
+
"!**/*.spec.*",
|
|
61
|
+
"!**/*.stories.*",
|
|
62
|
+
"!**/*.test-story.*",
|
|
63
|
+
"!**/test-helpers.*"
|
|
56
64
|
]
|
|
57
65
|
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { buildAuthorizationServerMetadata } from "./authorization-server-metadata";
|
|
4
|
-
// Barrel import (deliverable 2.2): the builder must be re-exported from the
|
|
5
|
-
// package entry so the future `@12-apps/mcp` extraction inherits both halves.
|
|
6
|
-
import { buildAuthorizationServerMetadata as buildFromBarrel } from "../index";
|
|
7
|
-
|
|
8
|
-
describe("buildAuthorizationServerMetadata", () => {
|
|
9
|
-
it("derives every endpoint from the issuer and fixes the RFC 8414 arrays", () => {
|
|
10
|
-
const meta = buildAuthorizationServerMetadata({
|
|
11
|
-
issuer: "https://app.example.com",
|
|
12
|
-
scopesSupported: ["mcp:read", "mcp:write"],
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
expect(meta.issuer).toBe("https://app.example.com");
|
|
16
|
-
expect(meta.authorization_endpoint).toBe(
|
|
17
|
-
"https://app.example.com/api/oauth/authorize",
|
|
18
|
-
);
|
|
19
|
-
expect(meta.token_endpoint).toBe("https://app.example.com/api/oauth/token");
|
|
20
|
-
expect(meta.registration_endpoint).toBe(
|
|
21
|
-
"https://app.example.com/api/oauth/register",
|
|
22
|
-
);
|
|
23
|
-
expect(meta.jwks_uri).toBe("https://app.example.com/.well-known/jwks.json");
|
|
24
|
-
|
|
25
|
-
// Fixed per the OAuth 2.1 + PKCE contract — must never drift.
|
|
26
|
-
expect(meta.response_types_supported).toEqual(["code"]);
|
|
27
|
-
expect(meta.grant_types_supported).toEqual([
|
|
28
|
-
"authorization_code",
|
|
29
|
-
"refresh_token",
|
|
30
|
-
]);
|
|
31
|
-
expect(meta.code_challenge_methods_supported).toEqual(["S256"]);
|
|
32
|
-
expect(meta.token_endpoint_auth_methods_supported).toEqual([
|
|
33
|
-
"none",
|
|
34
|
-
"client_secret_basic",
|
|
35
|
-
]);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("passes the supplied scopes through to scopes_supported", () => {
|
|
39
|
-
const meta = buildAuthorizationServerMetadata({
|
|
40
|
-
issuer: "https://tenant.futuredrink.com.br",
|
|
41
|
-
scopesSupported: ["mcp:read"],
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
expect(meta.scopes_supported).toEqual(["mcp:read"]);
|
|
45
|
-
// Endpoints stay on the supplied issuer origin.
|
|
46
|
-
expect(meta.issuer).toBe("https://tenant.futuredrink.com.br");
|
|
47
|
-
expect(meta.authorization_endpoint).toBe(
|
|
48
|
-
"https://tenant.futuredrink.com.br/api/oauth/authorize",
|
|
49
|
-
);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("honours a custom token_endpoint_auth_methods override", () => {
|
|
53
|
-
const meta = buildAuthorizationServerMetadata({
|
|
54
|
-
issuer: "https://app.example.com",
|
|
55
|
-
scopesSupported: ["mcp:read", "mcp:write"],
|
|
56
|
-
tokenEndpointAuthMethods: ["none"],
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
expect(meta.token_endpoint_auth_methods_supported).toEqual(["none"]);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("is re-exported from the package barrel", () => {
|
|
63
|
-
expect(buildFromBarrel).toBe(buildAuthorizationServerMetadata);
|
|
64
|
-
|
|
65
|
-
const meta = buildFromBarrel({
|
|
66
|
-
issuer: "https://app.example.com",
|
|
67
|
-
scopesSupported: ["mcp:read", "mcp:write"],
|
|
68
|
-
});
|
|
69
|
-
expect(meta.code_challenge_methods_supported).toEqual(["S256"]);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { bearerChallenge, buildProtectedResourceMetadata } from "./resource-metadata";
|
|
4
|
-
|
|
5
|
-
describe("buildProtectedResourceMetadata", () => {
|
|
6
|
-
it("emits the RFC 9728 fields", () => {
|
|
7
|
-
const meta = buildProtectedResourceMetadata({
|
|
8
|
-
resource: "https://app.example.com/api/mcp",
|
|
9
|
-
authorizationServers: ["https://app.example.com"],
|
|
10
|
-
scopesSupported: ["mcp:read"],
|
|
11
|
-
});
|
|
12
|
-
expect(meta).toEqual({
|
|
13
|
-
resource: "https://app.example.com/api/mcp",
|
|
14
|
-
authorization_servers: ["https://app.example.com"],
|
|
15
|
-
bearer_methods_supported: ["header"],
|
|
16
|
-
scopes_supported: ["mcp:read"],
|
|
17
|
-
});
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("omits scopes when not provided", () => {
|
|
21
|
-
const meta = buildProtectedResourceMetadata({
|
|
22
|
-
resource: "r",
|
|
23
|
-
authorizationServers: ["a"],
|
|
24
|
-
});
|
|
25
|
-
expect(meta.scopes_supported).toBeUndefined();
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe("bearerChallenge", () => {
|
|
30
|
-
it("points at the resource metadata and carries the error", () => {
|
|
31
|
-
const challenge = bearerChallenge({
|
|
32
|
-
resourceMetadataUrl: "https://app.example.com/.well-known/oauth-protected-resource",
|
|
33
|
-
error: "invalid_token",
|
|
34
|
-
errorDescription: "expired",
|
|
35
|
-
});
|
|
36
|
-
expect(challenge).toBe(
|
|
37
|
-
'Bearer resource_metadata="https://app.example.com/.well-known/oauth-protected-resource", error="invalid_token", error_description="expired"',
|
|
38
|
-
);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("emits a bare challenge when no error is given", () => {
|
|
42
|
-
expect(bearerChallenge({ resourceMetadataUrl: "https://x/.well-known/oauth-protected-resource" })).toBe(
|
|
43
|
-
'Bearer resource_metadata="https://x/.well-known/oauth-protected-resource"',
|
|
44
|
-
);
|
|
45
|
-
});
|
|
46
|
-
});
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { dispatchTool, DispatchInputError } from "./proxy";
|
|
4
|
-
import type { GeneratedTool } from "../types";
|
|
5
|
-
|
|
6
|
-
const getTool: GeneratedTool = {
|
|
7
|
-
name: "getProduct",
|
|
8
|
-
description: "",
|
|
9
|
-
method: "GET",
|
|
10
|
-
path: "/products/{id}",
|
|
11
|
-
inputSchema: {},
|
|
12
|
-
annotations: {
|
|
13
|
-
title: "Fixture tool",
|
|
14
|
-
readOnlyHint: true,
|
|
15
|
-
openWorldHint: false,
|
|
16
|
-
destructiveHint: false,
|
|
17
|
-
},
|
|
18
|
-
parameters: [
|
|
19
|
-
{ name: "id", in: "path", required: true, schema: {} },
|
|
20
|
-
{ name: "include", in: "query", required: false, schema: {} },
|
|
21
|
-
{ name: "x-trace", in: "header", required: false, schema: {} },
|
|
22
|
-
],
|
|
23
|
-
bodyProps: [],
|
|
24
|
-
bodyIsWhole: false,
|
|
25
|
-
mutating: false,
|
|
26
|
-
security: [],
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const postTool: GeneratedTool = {
|
|
30
|
-
name: "createProduct",
|
|
31
|
-
description: "",
|
|
32
|
-
method: "POST",
|
|
33
|
-
path: "/products",
|
|
34
|
-
inputSchema: {},
|
|
35
|
-
annotations: {
|
|
36
|
-
title: "Fixture tool",
|
|
37
|
-
readOnlyHint: false,
|
|
38
|
-
openWorldHint: true,
|
|
39
|
-
destructiveHint: false,
|
|
40
|
-
},
|
|
41
|
-
parameters: [],
|
|
42
|
-
bodyProps: ["name", "priceCents"],
|
|
43
|
-
bodyIsWhole: false,
|
|
44
|
-
mutating: true,
|
|
45
|
-
security: [],
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
interface Captured {
|
|
49
|
-
url: string;
|
|
50
|
-
init: RequestInit;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function fakeFetch(
|
|
54
|
-
status: number,
|
|
55
|
-
payload: unknown,
|
|
56
|
-
captured: Captured[],
|
|
57
|
-
): typeof fetch {
|
|
58
|
-
return (async (url: string, init: RequestInit) => {
|
|
59
|
-
captured.push({ url, init });
|
|
60
|
-
return new Response(JSON.stringify(payload), {
|
|
61
|
-
status,
|
|
62
|
-
headers: { "content-type": "application/json" },
|
|
63
|
-
});
|
|
64
|
-
}) as unknown as typeof fetch;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
describe("dispatchTool", () => {
|
|
68
|
-
it("expands the path, forwards the bearer, and routes query + header args", async () => {
|
|
69
|
-
const captured: Captured[] = [];
|
|
70
|
-
const result = await dispatchTool(
|
|
71
|
-
getTool,
|
|
72
|
-
{ id: "abc 1", include: "variations", "x-trace": "t1" },
|
|
73
|
-
{
|
|
74
|
-
baseUrl: "https://app.example.com",
|
|
75
|
-
bearer: "tok123",
|
|
76
|
-
fetchImpl: fakeFetch(200, { ok: 1 }, captured),
|
|
77
|
-
},
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
expect(captured).toHaveLength(1);
|
|
81
|
-
const { url, init } = captured[0];
|
|
82
|
-
expect(url).toBe(
|
|
83
|
-
"https://app.example.com/products/abc%201?include=variations",
|
|
84
|
-
);
|
|
85
|
-
const headers = init.headers as Record<string, string>;
|
|
86
|
-
expect(headers.authorization).toBe("Bearer tok123");
|
|
87
|
-
expect(headers["x-trace"]).toBe("t1");
|
|
88
|
-
expect(init.body).toBeUndefined();
|
|
89
|
-
expect(result.ok).toBe(true);
|
|
90
|
-
expect(result.status).toBe(200);
|
|
91
|
-
expect(result.body).toEqual({ ok: 1 });
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("sends only known body props as a JSON body for a write", async () => {
|
|
95
|
-
const captured: Captured[] = [];
|
|
96
|
-
await dispatchTool(
|
|
97
|
-
postTool,
|
|
98
|
-
{ name: "Cola", priceCents: 500, sneaky: "drop-me" },
|
|
99
|
-
{
|
|
100
|
-
baseUrl: "https://app.example.com",
|
|
101
|
-
bearer: "t",
|
|
102
|
-
fetchImpl: fakeFetch(201, {}, captured),
|
|
103
|
-
},
|
|
104
|
-
);
|
|
105
|
-
const { init } = captured[0];
|
|
106
|
-
expect(init.method).toBe("POST");
|
|
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
|
-
);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("throws on a missing required path parameter", async () => {
|
|
117
|
-
await expect(
|
|
118
|
-
dispatchTool(
|
|
119
|
-
getTool,
|
|
120
|
-
{},
|
|
121
|
-
{
|
|
122
|
-
baseUrl: "https://app.example.com",
|
|
123
|
-
bearer: "t",
|
|
124
|
-
fetchImpl: fakeFetch(200, {}, []),
|
|
125
|
-
},
|
|
126
|
-
),
|
|
127
|
-
).rejects.toBeInstanceOf(DispatchInputError);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("surfaces a non-2xx response as ok:false", async () => {
|
|
131
|
-
const result = await dispatchTool(
|
|
132
|
-
getTool,
|
|
133
|
-
{ id: "1" },
|
|
134
|
-
{
|
|
135
|
-
baseUrl: "https://app.example.com",
|
|
136
|
-
bearer: "t",
|
|
137
|
-
fetchImpl: fakeFetch(403, { error: "forbidden" }, []),
|
|
138
|
-
},
|
|
139
|
-
);
|
|
140
|
-
expect(result.ok).toBe(false);
|
|
141
|
-
expect(result.status).toBe(403);
|
|
142
|
-
expect(result.body).toEqual({ error: "forbidden" });
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
// Regression (FUT-105 AC1): the proxy replay must carry the mint origin as the
|
|
146
|
-
// standard reverse-proxy forwarded headers so the wrapped route reconstructs the
|
|
147
|
-
// SAME origin the access token's `aud` was minted against. Without these, the
|
|
148
|
-
// wrapped guard's origin resolver defaults the proto to https and the aud check
|
|
149
|
-
// fails on an http dev origin → a valid Bearer 401s.
|
|
150
|
-
it("forwards the baseUrl origin as x-forwarded-proto / x-forwarded-host", async () => {
|
|
151
|
-
const captured: Captured[] = [];
|
|
152
|
-
await dispatchTool(
|
|
153
|
-
getTool,
|
|
154
|
-
{ id: "1" },
|
|
155
|
-
{
|
|
156
|
-
baseUrl: "http://localhost:4105",
|
|
157
|
-
bearer: "t",
|
|
158
|
-
fetchImpl: fakeFetch(200, {}, captured),
|
|
159
|
-
},
|
|
160
|
-
);
|
|
161
|
-
const headers = captured[0].init.headers as Record<string, string>;
|
|
162
|
-
expect(headers["x-forwarded-proto"]).toBe("http");
|
|
163
|
-
expect(headers["x-forwarded-host"]).toBe("localhost:4105");
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("derives forwarded proto/host from an https origin with a default port", async () => {
|
|
167
|
-
const captured: Captured[] = [];
|
|
168
|
-
await dispatchTool(
|
|
169
|
-
getTool,
|
|
170
|
-
{ id: "1" },
|
|
171
|
-
{
|
|
172
|
-
baseUrl: "https://menu.example.com",
|
|
173
|
-
bearer: "t",
|
|
174
|
-
fetchImpl: fakeFetch(200, {}, captured),
|
|
175
|
-
},
|
|
176
|
-
);
|
|
177
|
-
const headers = captured[0].init.headers as Record<string, string>;
|
|
178
|
-
expect(headers["x-forwarded-proto"]).toBe("https");
|
|
179
|
-
expect(headers["x-forwarded-host"]).toBe("menu.example.com");
|
|
180
|
-
});
|
|
181
|
-
});
|
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { generateTools, type OpenApiDocument } from "./generate";
|
|
4
|
-
|
|
5
|
-
const doc: OpenApiDocument = {
|
|
6
|
-
paths: {
|
|
7
|
-
"/products/{id}": {
|
|
8
|
-
get: {
|
|
9
|
-
operationId: "getProduct",
|
|
10
|
-
summary: "Fetch a product",
|
|
11
|
-
"x-mcp-tool-annotations": {
|
|
12
|
-
title: "Fixture tool",
|
|
13
|
-
readOnlyHint: true,
|
|
14
|
-
openWorldHint: false,
|
|
15
|
-
destructiveHint: false,
|
|
16
|
-
},
|
|
17
|
-
parameters: [
|
|
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
|
-
},
|
|
30
|
-
],
|
|
31
|
-
responses: {
|
|
32
|
-
"200": {
|
|
33
|
-
content: { "application/json": { schema: { type: "object" } } },
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
security: [{ bearerAuth: [] }],
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
"/products": {
|
|
40
|
-
post: {
|
|
41
|
-
summary: "Create a product",
|
|
42
|
-
"x-mcp-tool-annotations": {
|
|
43
|
-
title: "Fixture tool",
|
|
44
|
-
readOnlyHint: false,
|
|
45
|
-
openWorldHint: true,
|
|
46
|
-
destructiveHint: false,
|
|
47
|
-
},
|
|
48
|
-
requestBody: {
|
|
49
|
-
required: true,
|
|
50
|
-
content: {
|
|
51
|
-
"application/json": {
|
|
52
|
-
schema: {
|
|
53
|
-
type: "object",
|
|
54
|
-
required: ["name"],
|
|
55
|
-
properties: {
|
|
56
|
-
name: { type: "string" },
|
|
57
|
-
priceCents: { type: "integer" },
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
},
|
|
64
|
-
get: {
|
|
65
|
-
operationId: "listProducts",
|
|
66
|
-
tags: ["internal"],
|
|
67
|
-
"x-mcp-tool-annotations": {
|
|
68
|
-
title: "Fixture tool",
|
|
69
|
-
readOnlyHint: true,
|
|
70
|
-
openWorldHint: false,
|
|
71
|
-
destructiveHint: false,
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
describe("generateTools", () => {
|
|
79
|
-
it("maps a GET operation with path + query params", () => {
|
|
80
|
-
const tools = generateTools(doc);
|
|
81
|
-
const get = tools.find((t) => t.name === "getProduct");
|
|
82
|
-
expect(get).toBeDefined();
|
|
83
|
-
expect(get?.method).toBe("GET");
|
|
84
|
-
expect(get?.path).toBe("/products/{id}");
|
|
85
|
-
expect(get?.mutating).toBe(false);
|
|
86
|
-
expect(get?.description).toBe("Fetch a product");
|
|
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
|
-
});
|
|
94
|
-
// path param is required even though only query is marked optional
|
|
95
|
-
expect(get?.inputSchema.required).toEqual(["id"]);
|
|
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
|
-
]);
|
|
104
|
-
expect(get?.bodyProps).toEqual([]);
|
|
105
|
-
expect(get?.outputSchema).toEqual({ type: "object" });
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("flattens an object request body and records bodyProps + required", () => {
|
|
109
|
-
const post = generateTools(doc).find(
|
|
110
|
-
(t) => t.path === "/products" && t.method === "POST",
|
|
111
|
-
);
|
|
112
|
-
expect(post?.mutating).toBe(true);
|
|
113
|
-
expect(post?.name).toBe("post_products"); // no operationId -> slug
|
|
114
|
-
expect(post?.bodyProps).toEqual(["name", "priceCents"]);
|
|
115
|
-
expect(post?.bodyIsWhole).toBe(false);
|
|
116
|
-
expect(post?.inputSchema.required).toEqual(["name"]);
|
|
117
|
-
expect(Object.keys(post?.inputSchema.properties as object)).toEqual([
|
|
118
|
-
"name",
|
|
119
|
-
"priceCents",
|
|
120
|
-
]);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("excludes operations by tag", () => {
|
|
124
|
-
const names = generateTools(doc, { excludeTags: ["internal"] }).map(
|
|
125
|
-
(t) => t.name,
|
|
126
|
-
);
|
|
127
|
-
expect(names).not.toContain("listProducts");
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("filters by method", () => {
|
|
131
|
-
const methods = generateTools(doc, { includeMethods: ["get"] }).map(
|
|
132
|
-
(t) => t.method,
|
|
133
|
-
);
|
|
134
|
-
expect(new Set(methods)).toEqual(new Set(["GET"]));
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("is deterministic across calls", () => {
|
|
138
|
-
expect(generateTools(doc)).toEqual(generateTools(doc));
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("exposes a non-object body as a single verbatim property", () => {
|
|
142
|
-
const wholeBodyDoc: OpenApiDocument = {
|
|
143
|
-
paths: {
|
|
144
|
-
"/raw": {
|
|
145
|
-
post: {
|
|
146
|
-
operationId: "postRaw",
|
|
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
|
-
},
|
|
157
|
-
},
|
|
158
|
-
},
|
|
159
|
-
},
|
|
160
|
-
};
|
|
161
|
-
const tool = generateTools(wholeBodyDoc)[0];
|
|
162
|
-
expect(tool.bodyIsWhole).toBe(true);
|
|
163
|
-
expect(tool.bodyProps).toEqual([]);
|
|
164
|
-
expect(tool.inputSchema.required).toEqual(["body"]);
|
|
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"]);
|
|
218
|
-
});
|
|
219
|
-
});
|
package/src/openapi/refs.test.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { inlineSchemaRefs, UnsupportedSchemaError } from "./refs";
|
|
4
|
-
|
|
5
|
-
describe("inlineSchemaRefs", () => {
|
|
6
|
-
it("returns a flat schema structurally unchanged (no-op)", () => {
|
|
7
|
-
const flat = {
|
|
8
|
-
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
9
|
-
type: "object",
|
|
10
|
-
properties: { data: { type: "array", items: { type: "string" } } },
|
|
11
|
-
required: ["data"],
|
|
12
|
-
additionalProperties: false,
|
|
13
|
-
};
|
|
14
|
-
expect(inlineSchemaRefs(flat)).toEqual(flat);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("inlines a $defs reference and drops the $defs container", () => {
|
|
18
|
-
const schema = {
|
|
19
|
-
type: "object",
|
|
20
|
-
properties: { card: { $ref: "#/$defs/Card" } },
|
|
21
|
-
$defs: {
|
|
22
|
-
Card: { type: "object", properties: { last4: { type: "string" } } },
|
|
23
|
-
},
|
|
24
|
-
};
|
|
25
|
-
expect(inlineSchemaRefs(schema)).toEqual({
|
|
26
|
-
type: "object",
|
|
27
|
-
properties: { card: { type: "object", properties: { last4: { type: "string" } } } },
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("resolves diamond reuse (same def on sibling branches) without a cycle error", () => {
|
|
32
|
-
const schema = {
|
|
33
|
-
type: "object",
|
|
34
|
-
properties: {
|
|
35
|
-
a: { $ref: "#/$defs/Widget" },
|
|
36
|
-
b: { $ref: "#/$defs/Widget" },
|
|
37
|
-
},
|
|
38
|
-
$defs: { Widget: { type: "object", properties: { x: { type: "string" } } } },
|
|
39
|
-
};
|
|
40
|
-
const inlined = inlineSchemaRefs(schema) as {
|
|
41
|
-
properties: { a: unknown; b: unknown };
|
|
42
|
-
};
|
|
43
|
-
expect(inlined.properties.a).toEqual({ type: "object", properties: { x: { type: "string" } } });
|
|
44
|
-
expect(inlined.properties.a).toEqual(inlined.properties.b);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("supports #/definitions and #/components/schemas pointers", () => {
|
|
48
|
-
const legacy = {
|
|
49
|
-
type: "object",
|
|
50
|
-
properties: { v: { $ref: "#/definitions/V" } },
|
|
51
|
-
definitions: { V: { type: "number" } },
|
|
52
|
-
};
|
|
53
|
-
expect(inlineSchemaRefs(legacy)).toEqual({
|
|
54
|
-
type: "object",
|
|
55
|
-
properties: { v: { type: "number" } },
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("throws on a recursive schema", () => {
|
|
60
|
-
const recursive = {
|
|
61
|
-
type: "object",
|
|
62
|
-
properties: { child: { $ref: "#/$defs/Node" } },
|
|
63
|
-
$defs: {
|
|
64
|
-
Node: { type: "object", properties: { next: { $ref: "#/$defs/Node" } } },
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
expect(() => inlineSchemaRefs(recursive)).toThrow(UnsupportedSchemaError);
|
|
68
|
-
expect(() => inlineSchemaRefs(recursive)).toThrow(/Recursive schema not supported: Node/);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("throws on an unresolved reference", () => {
|
|
72
|
-
expect(() => inlineSchemaRefs({ $ref: "#/$defs/Missing" })).toThrow(/Unresolved \$ref/);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("throws on an unsupported (external) pointer", () => {
|
|
76
|
-
expect(() => inlineSchemaRefs({ $ref: "https://example.com/schema.json" })).toThrow(
|
|
77
|
-
/Unsupported \$ref pointer/,
|
|
78
|
-
);
|
|
79
|
-
});
|
|
80
|
-
});
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { buildManifest, serializeManifest } from "./manifest";
|
|
4
|
-
import type { GeneratedTool } from "../types";
|
|
5
|
-
|
|
6
|
-
function tool(name: string): GeneratedTool {
|
|
7
|
-
return {
|
|
8
|
-
name,
|
|
9
|
-
description: `desc ${name}`,
|
|
10
|
-
method: "GET",
|
|
11
|
-
path: `/${name}`,
|
|
12
|
-
inputSchema: { type: "object", properties: {} },
|
|
13
|
-
annotations: {
|
|
14
|
-
readOnlyHint: true,
|
|
15
|
-
openWorldHint: false,
|
|
16
|
-
destructiveHint: false,
|
|
17
|
-
},
|
|
18
|
-
parameters: [],
|
|
19
|
-
bodyProps: [],
|
|
20
|
-
bodyIsWhole: false,
|
|
21
|
-
mutating: false,
|
|
22
|
-
security: [],
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
describe("buildManifest", () => {
|
|
27
|
-
it("sorts tools by name regardless of input order", () => {
|
|
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
|
-
]);
|
|
40
|
-
expect(manifest.version).toBe(1);
|
|
41
|
-
expect(manifest.source).toBe("test");
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe("serializeManifest", () => {
|
|
46
|
-
it("is deterministic and ends with a trailing newline", () => {
|
|
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
|
-
);
|
|
53
|
-
expect(a).toBe(b);
|
|
54
|
-
expect(a.endsWith("\n")).toBe(true);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("deep-sorts object keys so key order never churns the diff", () => {
|
|
58
|
-
const t1 = tool("x");
|
|
59
|
-
const t2: GeneratedTool = { ...tool("x") };
|
|
60
|
-
// Same data, different key insertion order in inputSchema.
|
|
61
|
-
t1.inputSchema = { type: "object", properties: { b: {}, a: {} } };
|
|
62
|
-
t2.inputSchema = { properties: { a: {}, b: {} }, type: "object" };
|
|
63
|
-
const s1 = serializeManifest(
|
|
64
|
-
buildManifest([t1], { version: 1, source: "s" }),
|
|
65
|
-
);
|
|
66
|
-
const s2 = serializeManifest(
|
|
67
|
-
buildManifest([t2], { version: 1, source: "s" }),
|
|
68
|
-
);
|
|
69
|
-
expect(s1).toBe(s2);
|
|
70
|
-
});
|
|
71
|
-
});
|
|
@@ -1,47 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { createToolRegistry, HTTP_STATUS_META_KEY } from "./registry";
|
|
4
|
-
import type { GeneratedTool, RequestAuth } from "../types";
|
|
5
|
-
|
|
6
|
-
const readTool: GeneratedTool = {
|
|
7
|
-
name: "getThing",
|
|
8
|
-
description: "Get a thing",
|
|
9
|
-
method: "GET",
|
|
10
|
-
path: "/things/{id}",
|
|
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
|
-
},
|
|
23
|
-
parameters: [{ name: "id", in: "path", required: true, schema: {} }],
|
|
24
|
-
bodyProps: [],
|
|
25
|
-
bodyIsWhole: false,
|
|
26
|
-
mutating: false,
|
|
27
|
-
security: [],
|
|
28
|
-
};
|
|
29
|
-
|
|
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
|
-
};
|
|
42
|
-
|
|
43
|
-
const auth: RequestAuth = { bearer: "tok" };
|
|
44
|
-
|
|
45
|
-
function okFetch(): typeof fetch {
|
|
46
|
-
return (async () =>
|
|
47
|
-
new Response(JSON.stringify({ id: "1" }), {
|
|
48
|
-
status: 200,
|
|
49
|
-
headers: { "content-type": "application/json" },
|
|
50
|
-
})) as unknown as typeof fetch;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function forbiddenFetch(): typeof fetch {
|
|
54
|
-
return (async () =>
|
|
55
|
-
new Response(JSON.stringify({ error: "forbidden" }), {
|
|
56
|
-
status: 403,
|
|
57
|
-
headers: { "content-type": "application/json" },
|
|
58
|
-
})) as unknown as typeof fetch;
|
|
59
|
-
}
|
|
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
|
-
|
|
77
|
-
describe("createToolRegistry", () => {
|
|
78
|
-
it("lists tools as MCP descriptors and honours the visibility filter", () => {
|
|
79
|
-
const registry = createToolRegistry({
|
|
80
|
-
tools: [readTool, writeTool],
|
|
81
|
-
baseUrl: "https://app.example.com",
|
|
82
|
-
isVisible: (tool) => !tool.mutating,
|
|
83
|
-
});
|
|
84
|
-
const names = registry.listTools().map((t) => t.name);
|
|
85
|
-
expect(names).toEqual(["getThing"]);
|
|
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
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("returns an error result for an unknown tool", async () => {
|
|
120
|
-
const registry = createToolRegistry({
|
|
121
|
-
tools: [readTool],
|
|
122
|
-
baseUrl: "https://app.example.com",
|
|
123
|
-
});
|
|
124
|
-
const result = await registry.callTool("nope", {}, auth);
|
|
125
|
-
expect(result.isError).toBe(true);
|
|
126
|
-
expect(result.content[0].text).toContain("Unknown tool");
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("proxies a successful call with text and schema-matching structured content", async () => {
|
|
130
|
-
const registry = createToolRegistry({
|
|
131
|
-
tools: [readTool],
|
|
132
|
-
baseUrl: "https://app.example.com",
|
|
133
|
-
fetchImpl: okFetch(),
|
|
134
|
-
});
|
|
135
|
-
const result = await registry.callTool("getThing", { id: "1" }, auth);
|
|
136
|
-
expect(result.isError).toBe(false);
|
|
137
|
-
expect(JSON.parse(result.content[0].text)).toEqual({ id: "1" });
|
|
138
|
-
expect(result.structuredContent).toEqual({ id: "1" });
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("marks an upstream 403 as an error result (authz decided upstream)", async () => {
|
|
142
|
-
const registry = createToolRegistry({
|
|
143
|
-
tools: [readTool],
|
|
144
|
-
baseUrl: "https://app.example.com",
|
|
145
|
-
fetchImpl: forbiddenFetch(),
|
|
146
|
-
});
|
|
147
|
-
const result = await registry.callTool("getThing", { id: "1" }, auth);
|
|
148
|
-
expect(result.isError).toBe(true);
|
|
149
|
-
expect(result.content[0].text).toContain("forbidden");
|
|
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
|
-
});
|
|
188
|
-
});
|