@12-apps/mcp 1.0.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/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @12-apps/mcp
2
+
3
+ App-agnostic core for exposing an app's HTTP endpoints as an MCP server, where
4
+ **the agent acts with exactly the calling user's permissions**.
5
+
6
+ ## The idea
7
+
8
+ Generate **one MCP tool per OpenAPI operation**, and dispatch each tool call by
9
+ **proxying to the real endpoint carrying the caller's bearer token**. Because the
10
+ proxy hits the same endpoints a browser would, all existing auth/authorization
11
+ (session guards, tenant scoping, role checks) runs unchanged — this package holds
12
+ **zero** authorization logic. An agent can do precisely what the user can, no
13
+ more.
14
+
15
+ This is the passthrough pattern, not the "golden catalog" curation pattern: 1:1
16
+ tools, no hand-written field maps. That is what makes it generatable and portable
17
+ across apps.
18
+
19
+ ## The two invariants a consuming app must satisfy
20
+
21
+ 1. **Schema'd HTTP surface** — every agent-exposable operation is an HTTP endpoint
22
+ described in an OpenAPI document generated from runtime schemas (Zod →
23
+ OpenAPI). That document is this package's only input.
24
+ 2. **One standard bearer auth** — every endpoint authenticates a caller from an
25
+ `Authorization: Bearer <token>` resolving to the same identity/permissions as a
26
+ normal session. The proxy forwards the token blindly.
27
+
28
+ ## What this package provides
29
+
30
+ | Export | Role |
31
+ |--------|------|
32
+ | `generateTools(doc, opts)` | OpenAPI operations → `GeneratedTool[]` (input schema + HTTP routing metadata). Deterministic. |
33
+ | `createToolRegistry({ tools, baseUrl })` | Transport-agnostic `listTools` / `callTool`; `callTool` proxies with the caller's bearer. |
34
+ | `dispatchTool(tool, args, cfg)` | The generic auth-proxy: routes flat args → path/query/header/body, forwards the bearer. |
35
+ | `buildManifest` / `serializeManifest` | The committed drift artifact `mcp:check` regenerates + diffs (see `12-apps/ci` `mcp-contract.yml`). |
36
+ | `buildProtectedResourceMetadata` / `bearerChallenge` | OAuth 2.0 Protected Resource Metadata (RFC 9728) + `WWW-Authenticate` for the resource-server mode. |
37
+
38
+ ## What the app provides (not here)
39
+
40
+ - The **OpenAPI document** (from its Zod-schema'd routes).
41
+ - The **`AuthResolver`** — validates the incoming access token (OAuth
42
+ resource-server: signature / audience / scope) and returns the bearer to
43
+ forward. In future-pay this is the `getRequestSession()` shim over NextAuth
44
+ `auth()`.
45
+ - Binding `ToolRegistry` to the **MCP transport** (the `@modelcontextprotocol/sdk`
46
+ HTTP server at `/api/mcp`).
47
+
48
+ ## Status
49
+
50
+ Scaffold: the generator, dispatcher, registry, manifest, and OAuth
51
+ resource-metadata helpers are implemented and dependency-light (no MCP SDK). The
52
+ SDK/HTTP transport binding and the app-side `AuthResolver` land in the pilot's
53
+ next phase (`apps/web`). See the pilot design notes under `docs/`.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@12-apps/mcp",
3
+ "version": "1.0.0",
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).",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "devDependencies": {
10
+ "@types/node": "^22.15.3",
11
+ "eslint": "^9.39.1",
12
+ "typescript": "^5.8.2",
13
+ "vitest": "^3.2.4",
14
+ "@12-apps/typescript-config": "0.0.0"
15
+ },
16
+ "engines": {
17
+ "node": ">=22.0.0"
18
+ },
19
+ "license": "MIT",
20
+ "publishConfig": {
21
+ "registry": "https://registry.npmjs.org",
22
+ "access": "restricted"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/12-apps/shared-packages.git",
27
+ "directory": "packages/mcp"
28
+ },
29
+ "files": [
30
+ "src",
31
+ "dist",
32
+ "prisma",
33
+ "*.js",
34
+ "*.mjs",
35
+ "*.md"
36
+ ],
37
+ "scripts": {
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
+ }
@@ -0,0 +1,71 @@
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
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * OAuth 2.0 Authorization Server Metadata (RFC 8414), the discovery half that
3
+ * complements the RFC 9728 protected-resource metadata in `resource-metadata.ts`.
4
+ * Agent hosts (Claude.ai / ChatGPT connectors) read
5
+ * `/.well-known/oauth-authorization-server` to learn where to start the OAuth
6
+ * 2.1 Authorization Code + PKCE flow.
7
+ *
8
+ * This builder is a pure function of `(issuer, scopes)` with no Next.js/request
9
+ * coupling, so it can move verbatim into the future `@12-apps/mcp` extraction.
10
+ * It derives every endpoint from the same issuer origin the resource metadata
11
+ * advertises, so the two discovery documents cannot drift.
12
+ */
13
+
14
+ export interface AuthorizationServerMetadataInput {
15
+ /** Authorization server issuer URL (origin) — also the resource issuer. */
16
+ issuer: string;
17
+ /** Scopes the authorization server advertises (from the shared scope source). */
18
+ scopesSupported: string[];
19
+ /**
20
+ * Client authentication methods the token endpoint accepts. Defaults to
21
+ * public PKCE clients (`none`) plus HTTP Basic client-secret auth.
22
+ */
23
+ tokenEndpointAuthMethods?: string[];
24
+ }
25
+
26
+ /** The RFC 8414 document served at `/.well-known/oauth-authorization-server`. */
27
+ export interface AuthorizationServerMetadata {
28
+ issuer: string;
29
+ authorization_endpoint: string;
30
+ token_endpoint: string;
31
+ registration_endpoint: string;
32
+ jwks_uri: string;
33
+ scopes_supported: string[];
34
+ response_types_supported: string[];
35
+ grant_types_supported: string[];
36
+ code_challenge_methods_supported: string[];
37
+ token_endpoint_auth_methods_supported: string[];
38
+ }
39
+
40
+ const AUTHORIZE_PATH = "/api/oauth/authorize";
41
+ const TOKEN_PATH = "/api/oauth/token";
42
+ const REGISTRATION_PATH = "/api/oauth/register";
43
+ const JWKS_PATH = "/.well-known/jwks.json";
44
+
45
+ const DEFAULT_TOKEN_ENDPOINT_AUTH_METHODS = [
46
+ "none",
47
+ "client_secret_basic",
48
+ ] as const;
49
+
50
+ /**
51
+ * Build the RFC 8414 authorization-server metadata document from an issuer
52
+ * origin and the supported scopes. Endpoints are derived from `issuer`; the
53
+ * OAuth 2.1 + PKCE contract fixes `response_types_supported`,
54
+ * `grant_types_supported`, and `code_challenge_methods_supported`.
55
+ */
56
+ export function buildAuthorizationServerMetadata(
57
+ input: AuthorizationServerMetadataInput,
58
+ ): AuthorizationServerMetadata {
59
+ const origin = input.issuer;
60
+ return {
61
+ issuer: origin,
62
+ authorization_endpoint: `${origin}${AUTHORIZE_PATH}`,
63
+ token_endpoint: `${origin}${TOKEN_PATH}`,
64
+ registration_endpoint: `${origin}${REGISTRATION_PATH}`,
65
+ jwks_uri: `${origin}${JWKS_PATH}`,
66
+ scopes_supported: input.scopesSupported,
67
+ response_types_supported: ["code"],
68
+ grant_types_supported: ["authorization_code", "refresh_token"],
69
+ code_challenge_methods_supported: ["S256"],
70
+ token_endpoint_auth_methods_supported:
71
+ input.tokenEndpointAuthMethods ?? [...DEFAULT_TOKEN_ENDPOINT_AUTH_METHODS],
72
+ };
73
+ }
@@ -0,0 +1,46 @@
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
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * OAuth 2.0 Protected Resource Metadata (RFC 9728), as required by the MCP
3
+ * authorization spec: the MCP endpoint is an OAuth *resource server*. Agent hosts
4
+ * (Claude.ai / ChatGPT connectors) discover where to obtain a token by reading
5
+ * `/.well-known/oauth-protected-resource`, and on a 401 the resource server points
6
+ * them at that document via a `WWW-Authenticate` challenge.
7
+ *
8
+ * This module only builds the discovery documents/headers — validating the
9
+ * resulting access token is the app's job (the {@link import("../types").AuthResolver}),
10
+ * because it depends on the app's authorization server and key material.
11
+ */
12
+
13
+ export interface ProtectedResourceMetadataInput {
14
+ /** Canonical resource identifier — the MCP endpoint URL (the token audience). */
15
+ resource: string;
16
+ /** Authorization server issuer URLs that can mint tokens for this resource. */
17
+ authorizationServers: string[];
18
+ /** Scopes the resource server understands (advertised to clients). */
19
+ scopesSupported?: string[];
20
+ /** Human-facing docs URL for the protected resource, if any. */
21
+ resourceDocumentation?: string;
22
+ }
23
+
24
+ /** The RFC 9728 metadata document served at `/.well-known/oauth-protected-resource`. */
25
+ export interface ProtectedResourceMetadata {
26
+ resource: string;
27
+ authorization_servers: string[];
28
+ bearer_methods_supported: string[];
29
+ scopes_supported?: string[];
30
+ resource_documentation?: string;
31
+ }
32
+
33
+ export function buildProtectedResourceMetadata(
34
+ input: ProtectedResourceMetadataInput,
35
+ ): ProtectedResourceMetadata {
36
+ return {
37
+ resource: input.resource,
38
+ authorization_servers: input.authorizationServers,
39
+ // MCP clients present the token in the Authorization header only.
40
+ bearer_methods_supported: ["header"],
41
+ ...(input.scopesSupported ? { scopes_supported: input.scopesSupported } : {}),
42
+ ...(input.resourceDocumentation
43
+ ? { resource_documentation: input.resourceDocumentation }
44
+ : {}),
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Build the `WWW-Authenticate` value for an unauthorized MCP response, pointing
50
+ * the client at the protected-resource metadata so it can start the OAuth flow.
51
+ * Per RFC 9728 §5.1 the challenge carries a `resource_metadata` parameter.
52
+ */
53
+ export function bearerChallenge(params: {
54
+ resourceMetadataUrl: string;
55
+ error?: "invalid_token" | "insufficient_scope";
56
+ errorDescription?: string;
57
+ }): string {
58
+ const parts = [`Bearer resource_metadata="${params.resourceMetadataUrl}"`];
59
+ if (params.error) parts.push(`error="${params.error}"`);
60
+ if (params.errorDescription) {
61
+ parts.push(`error_description="${params.errorDescription}"`);
62
+ }
63
+ return parts.join(", ");
64
+ }
65
+
66
+ /** Standard path for the protected-resource metadata document. */
67
+ export const PROTECTED_RESOURCE_METADATA_PATH =
68
+ "/.well-known/oauth-protected-resource";
@@ -0,0 +1,130 @@
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
+ parameters: [
13
+ { name: "id", in: "path", required: true, schema: {} },
14
+ { name: "include", in: "query", required: false, schema: {} },
15
+ { name: "x-trace", in: "header", required: false, schema: {} },
16
+ ],
17
+ bodyProps: [],
18
+ bodyIsWhole: false,
19
+ mutating: false,
20
+ security: [],
21
+ };
22
+
23
+ const postTool: GeneratedTool = {
24
+ name: "createProduct",
25
+ description: "",
26
+ method: "POST",
27
+ path: "/products",
28
+ inputSchema: {},
29
+ parameters: [],
30
+ bodyProps: ["name", "priceCents"],
31
+ bodyIsWhole: false,
32
+ mutating: true,
33
+ security: [],
34
+ };
35
+
36
+ interface Captured {
37
+ url: string;
38
+ init: RequestInit;
39
+ }
40
+
41
+ function fakeFetch(status: number, payload: unknown, captured: Captured[]): typeof fetch {
42
+ return (async (url: string, init: RequestInit) => {
43
+ captured.push({ url, init });
44
+ return new Response(JSON.stringify(payload), {
45
+ status,
46
+ headers: { "content-type": "application/json" },
47
+ });
48
+ }) as unknown as typeof fetch;
49
+ }
50
+
51
+ describe("dispatchTool", () => {
52
+ it("expands the path, forwards the bearer, and routes query + header args", async () => {
53
+ const captured: Captured[] = [];
54
+ const result = await dispatchTool(
55
+ getTool,
56
+ { id: "abc 1", include: "variations", "x-trace": "t1" },
57
+ { baseUrl: "https://app.example.com", bearer: "tok123", fetchImpl: fakeFetch(200, { ok: 1 }, captured) },
58
+ );
59
+
60
+ expect(captured).toHaveLength(1);
61
+ const { url, init } = captured[0];
62
+ expect(url).toBe("https://app.example.com/products/abc%201?include=variations");
63
+ const headers = init.headers as Record<string, string>;
64
+ expect(headers.authorization).toBe("Bearer tok123");
65
+ expect(headers["x-trace"]).toBe("t1");
66
+ expect(init.body).toBeUndefined();
67
+ expect(result.ok).toBe(true);
68
+ expect(result.status).toBe(200);
69
+ expect(result.body).toEqual({ ok: 1 });
70
+ });
71
+
72
+ it("sends only known body props as a JSON body for a write", async () => {
73
+ const captured: Captured[] = [];
74
+ await dispatchTool(
75
+ postTool,
76
+ { name: "Cola", priceCents: 500, sneaky: "drop-me" },
77
+ { baseUrl: "https://app.example.com", bearer: "t", fetchImpl: fakeFetch(201, {}, captured) },
78
+ );
79
+ const { init } = captured[0];
80
+ expect(init.method).toBe("POST");
81
+ expect(JSON.parse(init.body as string)).toEqual({ name: "Cola", priceCents: 500 });
82
+ expect((init.headers as Record<string, string>)["content-type"]).toBe("application/json");
83
+ });
84
+
85
+ it("throws on a missing required path parameter", async () => {
86
+ await expect(
87
+ dispatchTool(getTool, {}, { baseUrl: "https://app.example.com", bearer: "t", fetchImpl: fakeFetch(200, {}, []) }),
88
+ ).rejects.toBeInstanceOf(DispatchInputError);
89
+ });
90
+
91
+ it("surfaces a non-2xx response as ok:false", async () => {
92
+ const result = await dispatchTool(
93
+ getTool,
94
+ { id: "1" },
95
+ { baseUrl: "https://app.example.com", bearer: "t", fetchImpl: fakeFetch(403, { error: "forbidden" }, []) },
96
+ );
97
+ expect(result.ok).toBe(false);
98
+ expect(result.status).toBe(403);
99
+ expect(result.body).toEqual({ error: "forbidden" });
100
+ });
101
+
102
+ // Regression (FUT-105 AC1): the proxy replay must carry the mint origin as the
103
+ // standard reverse-proxy forwarded headers so the wrapped route reconstructs the
104
+ // SAME origin the access token's `aud` was minted against. Without these, the
105
+ // wrapped guard's origin resolver defaults the proto to https and the aud check
106
+ // fails on an http dev origin → a valid Bearer 401s.
107
+ it("forwards the baseUrl origin as x-forwarded-proto / x-forwarded-host", async () => {
108
+ const captured: Captured[] = [];
109
+ await dispatchTool(
110
+ getTool,
111
+ { id: "1" },
112
+ { baseUrl: "http://localhost:4105", bearer: "t", fetchImpl: fakeFetch(200, {}, captured) },
113
+ );
114
+ const headers = captured[0].init.headers as Record<string, string>;
115
+ expect(headers["x-forwarded-proto"]).toBe("http");
116
+ expect(headers["x-forwarded-host"]).toBe("localhost:4105");
117
+ });
118
+
119
+ it("derives forwarded proto/host from an https origin with a default port", async () => {
120
+ const captured: Captured[] = [];
121
+ await dispatchTool(
122
+ getTool,
123
+ { id: "1" },
124
+ { baseUrl: "https://menu.example.com", bearer: "t", fetchImpl: fakeFetch(200, {}, captured) },
125
+ );
126
+ const headers = captured[0].init.headers as Record<string, string>;
127
+ expect(headers["x-forwarded-proto"]).toBe("https");
128
+ expect(headers["x-forwarded-host"]).toBe("menu.example.com");
129
+ });
130
+ });
@@ -0,0 +1,126 @@
1
+ import type { DispatchConfig, DispatchResult, GeneratedTool } from "../types";
2
+
3
+ /** Raised when tool arguments cannot be routed onto the HTTP request. */
4
+ export class DispatchInputError extends Error {
5
+ constructor(message: string) {
6
+ super(message);
7
+ this.name = "DispatchInputError";
8
+ }
9
+ }
10
+
11
+ /** Expand a path template (`/products/{id}`) using the path args, URL-encoding each. */
12
+ function expandPath(
13
+ tool: GeneratedTool,
14
+ args: Record<string, unknown>,
15
+ ): string {
16
+ return tool.path.replace(/\{([^}]+)\}/g, (_match, key: string) => {
17
+ const value = args[key];
18
+ if (value === undefined || value === null) {
19
+ throw new DispatchInputError(`Missing required path parameter: ${key}`);
20
+ }
21
+ return encodeURIComponent(String(value));
22
+ });
23
+ }
24
+
25
+ /**
26
+ * Route the non-path parameters (query + header) from the flat args. A missing
27
+ * required parameter is a hard error; a missing optional one is simply omitted.
28
+ */
29
+ function routeParams(
30
+ tool: GeneratedTool,
31
+ args: Record<string, unknown>,
32
+ ): { query: URLSearchParams; headers: Record<string, string> } {
33
+ const query = new URLSearchParams();
34
+ const headers: Record<string, string> = {};
35
+
36
+ tool.parameters
37
+ .filter((param) => param.in !== "path")
38
+ .forEach((param) => {
39
+ const value = args[param.name];
40
+ if (value === undefined || value === null) {
41
+ if (param.required) {
42
+ throw new DispatchInputError(`Missing required ${param.in} parameter: ${param.name}`);
43
+ }
44
+ return;
45
+ }
46
+ if (param.in === "query") query.set(param.name, String(value));
47
+ else headers[param.name] = String(value);
48
+ });
49
+
50
+ return { query, headers };
51
+ }
52
+
53
+ /**
54
+ * Reconstruct the request body from the flat args using the routing metadata.
55
+ * Anything not claimed by a known body property is dropped — the input schema is
56
+ * `additionalProperties: false`, so a validated call never carries extras and an
57
+ * unvalidated one cannot smuggle fields upstream.
58
+ */
59
+ function routeBody(tool: GeneratedTool, args: Record<string, unknown>): unknown {
60
+ if (tool.bodyIsWhole) return args.body;
61
+ if (!tool.bodyProps.length) return undefined;
62
+ const payload: Record<string, unknown> = {};
63
+ tool.bodyProps.forEach((key) => {
64
+ if (args[key] !== undefined) payload[key] = args[key];
65
+ });
66
+ return Object.keys(payload).length ? payload : undefined;
67
+ }
68
+
69
+ /**
70
+ * Execute one generated tool by proxying to its HTTP endpoint, forwarding the
71
+ * caller's bearer verbatim. This function performs NO authorization — the
72
+ * endpoint does, exactly as it would for a first-party request. That is the whole
73
+ * point of the passthrough: the agent can do precisely what the user can.
74
+ */
75
+ export async function dispatchTool(
76
+ tool: GeneratedTool,
77
+ args: Record<string, unknown>,
78
+ config: DispatchConfig,
79
+ ): Promise<DispatchResult> {
80
+ const doFetch = config.fetchImpl ?? fetch;
81
+ const pathname = expandPath(tool, args);
82
+ const { query, headers } = routeParams(tool, args);
83
+ const body = routeBody(tool, args);
84
+
85
+ const url = new URL(pathname, config.baseUrl);
86
+ for (const [key, value] of query) url.searchParams.set(key, value);
87
+
88
+ // Carry the proxy origin as the standard reverse-proxy forwarded headers. The
89
+ // wrapped endpoint's auth guard re-derives the request origin (to check the
90
+ // access token's `aud`) WITHOUT a request object, so it can only see the origin
91
+ // through these headers. `baseUrl` is exactly the origin the token was minted
92
+ // and transport-verified against, so forwarding its scheme+host makes the
93
+ // wrapped guard reconstruct the SAME origin — the `aud` survives the replay in
94
+ // both dev (http) and prod (any proxied https origin). Omitting them let the
95
+ // guard default the scheme to https and 401 a valid http-minted bearer.
96
+ const base = new URL(config.baseUrl);
97
+
98
+ const init: RequestInit = {
99
+ method: tool.method,
100
+ headers: {
101
+ accept: "application/json",
102
+ authorization: `Bearer ${config.bearer}`,
103
+ "x-forwarded-proto": base.protocol.replace(/:$/, ""),
104
+ "x-forwarded-host": base.host,
105
+ ...headers,
106
+ },
107
+ };
108
+ if (body !== undefined) {
109
+ (init.headers as Record<string, string>)["content-type"] = "application/json";
110
+ init.body = JSON.stringify(body);
111
+ }
112
+
113
+ const response = await doFetch(url.toString(), init);
114
+ const text = await response.text();
115
+ let parsed: unknown = text;
116
+ const contentType = response.headers.get("content-type") ?? "";
117
+ if (contentType.includes("application/json") && text) {
118
+ try {
119
+ parsed = JSON.parse(text);
120
+ } catch {
121
+ parsed = text;
122
+ }
123
+ }
124
+
125
+ return { status: response.status, ok: response.ok, body: parsed };
126
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @12-apps/mcp — app-agnostic MCP server core.
3
+ *
4
+ * Turn an OpenAPI document into MCP tools (one per operation) and dispatch each
5
+ * tool call by proxying to the endpoint with the caller's bearer token, so an
6
+ * agent inherits exactly the user's permissions. The consuming app supplies the
7
+ * spec, the base URL, and an AuthResolver, and binds the ToolRegistry to the MCP
8
+ * transport (mounted at `/api/mcp`).
9
+ */
10
+
11
+ export * from "./types";
12
+ export { generateTools } from "./openapi/generate";
13
+ export { inlineSchemaRefs, UnsupportedSchemaError } from "./openapi/refs";
14
+ export type {
15
+ OpenApiDocument,
16
+ OpenApiOperation,
17
+ OpenApiParameter,
18
+ OpenApiRequestBody,
19
+ OpenApiResponse,
20
+ } from "./openapi/generate";
21
+ export { dispatchTool, DispatchInputError } from "./dispatch/proxy";
22
+ export {
23
+ createToolRegistry,
24
+ type ToolRegistry,
25
+ type RegistryOptions,
26
+ type McpToolDescriptor,
27
+ type McpToolResult,
28
+ } from "./server/registry";
29
+ export {
30
+ buildManifest,
31
+ serializeManifest,
32
+ type BuildManifestOptions,
33
+ } from "./server/manifest";
34
+ // OAuth discovery — both halves of the MCP auth story kept together so the
35
+ // future `@12-apps/mcp` extraction inherits them as one surface:
36
+ // RFC 9728 protected-resource metadata + RFC 8414 authorization-server metadata.
37
+ export {
38
+ buildProtectedResourceMetadata,
39
+ bearerChallenge,
40
+ PROTECTED_RESOURCE_METADATA_PATH,
41
+ type ProtectedResourceMetadata,
42
+ type ProtectedResourceMetadataInput,
43
+ } from "./auth/resource-metadata";
44
+ export {
45
+ buildAuthorizationServerMetadata,
46
+ type AuthorizationServerMetadata,
47
+ type AuthorizationServerMetadataInput,
48
+ } from "./auth/authorization-server-metadata";