@sezzlee/openapi-mcp 0.0.0-stage → 0.1.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.
@@ -0,0 +1,9 @@
1
+ export declare const directoryOf: (path: string) => string;
2
+ export declare function readText(path: string): Promise<string>;
3
+ /**
4
+ * Reads a file an external `$ref` names.
5
+ *
6
+ * Guard: the path is checked against the root on its realpath, not on the string, so neither
7
+ * `../` nor a symbolic link can make a document read a file outside the directory it came from.
8
+ */
9
+ export declare function readWithin(root: string, path: string): Promise<string>;
@@ -0,0 +1,21 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
3
+ export const directoryOf = (path) => dirname(resolve(path));
4
+ export function readText(path) {
5
+ return readFile(path, "utf8");
6
+ }
7
+ /**
8
+ * Reads a file an external `$ref` names.
9
+ *
10
+ * Guard: the path is checked against the root on its realpath, not on the string, so neither
11
+ * `../` nor a symbolic link can make a document read a file outside the directory it came from.
12
+ */
13
+ export async function readWithin(root, path) {
14
+ const base = await realpath(root);
15
+ const target = await realpath(resolve(base, path));
16
+ const offset = relative(base, target);
17
+ if (offset.startsWith("..") || isAbsolute(offset)) {
18
+ throw new Error(`sezzlee-openapi: '${path}' is outside the document's directory.`);
19
+ }
20
+ return readFile(target, "utf8");
21
+ }
@@ -0,0 +1,14 @@
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
+ import { type SearchRankerOptions } from "@sezzlee/core";
3
+ import type { GatewayCatalog } from "./catalog/build.js";
4
+ import { type InvokeLimits } from "./invoke/invoke.js";
5
+ import type { BoundedFetch } from "./net/fetch.js";
6
+ /** Where the http transport's auth gate leaves the token the caller's own was exchanged for. */
7
+ export declare const exchangedTokenKey = "sezzleeExchangedToken";
8
+ /**
9
+ * @param options.ranker a host ranker for `search_tools`, for an embedder; the CLI binds none
10
+ */
11
+ export interface OpenApiMcpServerOptions {
12
+ readonly ranker?: SearchRankerOptions;
13
+ }
14
+ export declare function createOpenApiMcpServer(gateway: GatewayCatalog, fetcher: BoundedFetch, limits: InvokeLimits, options?: OpenApiMcpServerOptions): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,112 @@
1
+ import { createRequire } from "node:module";
2
+ import { McpServer } from "@modelcontextprotocol/server";
3
+ import { assertCatalogValid, createDetail, defaultSearchLimit, emitGuarded, evaluateVisibility, invokeArgumentsDescription, invokeDescription, loadDescription, missingArgument, operationNameDescription, searchCatalog, searchDescription, searchDetailDescription, searchLimitDescription, searchQueryDescription, searchTagsDescription, textResult, unknownTool, wrongArgumentType, } from "@sezzlee/core";
4
+ import { z } from "zod";
5
+ import { invokeEntry } from "./invoke/invoke.js";
6
+ const { version } = createRequire(import.meta.url)("../package.json");
7
+ /** Where the http transport's auth gate leaves the token the caller's own was exchanged for. */
8
+ export const exchangedTokenKey = "sezzleeExchangedToken";
9
+ /**
10
+ * Guard: `name` binds as `unknown` so a call that misspells the argument reaches the handler and
11
+ * leaves as an sezzlee envelope; a `z.string()` would be rejected by the framework's validator with
12
+ * a bare text error the agent cannot parse. The published schema is byte-identical to the SDKs'.
13
+ */
14
+ function namedArgument(description) {
15
+ return z.unknown().optional().meta({ type: "string", description });
16
+ }
17
+ /**
18
+ * A document says which credential a call needs, never which caller may make it, so a caller is
19
+ * always of unknown identity here and only an explicitly anonymous operation is certain.
20
+ */
21
+ function decide(entry) {
22
+ return evaluateVisibility(entry.descriptor.auth, { identity: "unknown" });
23
+ }
24
+ const visible = (decision) => decision !== "deny";
25
+ export function createOpenApiMcpServer(gateway, fetcher, limits, options = {}) {
26
+ const { catalog } = gateway;
27
+ const budget = () => limits.maxResponseBytes;
28
+ const server = new McpServer({ name: "sezzlee-openapi", version });
29
+ server.registerTool("search_tools", {
30
+ description: searchDescription,
31
+ inputSchema: z.object({
32
+ query: z.string().default("").describe(searchQueryDescription),
33
+ limit: z
34
+ .number()
35
+ .int()
36
+ .default(defaultSearchLimit)
37
+ .describe(searchLimitDescription),
38
+ detail: z
39
+ .enum(["card", "schema"])
40
+ .catch("card")
41
+ .default("card")
42
+ .describe(searchDetailDescription),
43
+ tags: z
44
+ .array(z.string())
45
+ .describe(searchTagsDescription)
46
+ .optional()
47
+ .meta({ default: null }),
48
+ }),
49
+ }, async ({ query, limit, detail, tags }, ctx) => emitGuarded(budget, async () => {
50
+ assertCatalogValid(catalog.fatal);
51
+ const signal = ctx?.mcpReq?.signal;
52
+ return searchCatalog({
53
+ catalog,
54
+ query,
55
+ limit,
56
+ detail,
57
+ tags,
58
+ decide,
59
+ visible,
60
+ ...(options.ranker === undefined ? {} : { ranker: options.ranker }),
61
+ ...(signal === undefined ? {} : { signal }),
62
+ });
63
+ }));
64
+ server.registerTool("load_tool", {
65
+ description: loadDescription,
66
+ inputSchema: z
67
+ .object({ name: namedArgument(operationNameDescription) })
68
+ .meta({ required: ["name"] }),
69
+ }, async ({ name }) => emitGuarded(budget, async () => {
70
+ if (name === undefined) {
71
+ return missingArgument("load_tool", "name");
72
+ }
73
+ if (typeof name !== "string") {
74
+ return wrongArgumentType("load_tool", "name", name);
75
+ }
76
+ assertCatalogValid(catalog.fatal);
77
+ const entry = catalog.byName.get(name);
78
+ const decision = entry === undefined ? "deny" : decide(entry);
79
+ if (entry === undefined || !visible(decision)) {
80
+ return unknownTool(name);
81
+ }
82
+ return textResult(createDetail(entry.tool, decision), false);
83
+ }));
84
+ server.registerTool("invoke_tool", {
85
+ description: invokeDescription,
86
+ inputSchema: z
87
+ .object({
88
+ name: namedArgument(operationNameDescription),
89
+ arguments: z
90
+ .unknown()
91
+ .optional()
92
+ .meta({ description: invokeArgumentsDescription }),
93
+ })
94
+ .meta({ required: ["name", "arguments"] }),
95
+ }, async ({ name, arguments: args }, ctx) => emitGuarded(budget, async () => {
96
+ if (name === undefined) {
97
+ return missingArgument("invoke_tool", "name");
98
+ }
99
+ if (typeof name !== "string") {
100
+ return wrongArgumentType("invoke_tool", "name", name);
101
+ }
102
+ assertCatalogValid(catalog.fatal);
103
+ const entry = catalog.byName.get(name);
104
+ if (entry === undefined) {
105
+ return unknownTool(name);
106
+ }
107
+ const context = ctx;
108
+ const exchanged = context?.http?.authInfo?.extra?.[exchangedTokenKey];
109
+ return invokeEntry(entry, args, fetcher, limits, context?.mcpReq?.signal, typeof exchanged === "string" ? exchanged : undefined);
110
+ }));
111
+ return server;
112
+ }
@@ -0,0 +1,23 @@
1
+ import { type Server } from "node:http";
2
+ import { type McpServer } from "@modelcontextprotocol/server";
3
+ import { type TokenExchange } from "../credentials/token-exchange.js";
4
+ export interface HttpTransportOptions {
5
+ readonly host: string;
6
+ readonly port: number;
7
+ readonly path: string;
8
+ readonly resource: string;
9
+ readonly authorizationServers: readonly string[];
10
+ readonly allowedHostnames: readonly string[];
11
+ }
12
+ /**
13
+ * Serves the gateway over streamable HTTP, behind a bearer gate when token exchange is configured.
14
+ * Without it the server has no caller to authenticate; the config then only accepts a loopback
15
+ * host, so the operator's credentials are not offered to the network.
16
+ *
17
+ * Guard: the gate's verifier is the token exchange itself. The gateway cannot check a token it
18
+ * holds no key for, and the authorization server that can — it issued the token — does so while
19
+ * exchanging it; a refused exchange is `invalid_token`, so the client is told to re-authorize
20
+ * rather than handed a tool error it cannot act on. The caller's token never leaves this process
21
+ * except to that authorization server.
22
+ */
23
+ export declare function serveHttp(factory: () => McpServer, exchange: TokenExchange | undefined, options: HttpTransportOptions): Promise<Server>;
@@ -0,0 +1,88 @@
1
+ import { createServer } from "node:http";
2
+ import { toNodeHandler } from "@modelcontextprotocol/node";
3
+ import { createMcpHandler, getOAuthProtectedResourceMetadataUrl, hostHeaderValidationResponse, localhostAllowedHostnames, OAuthError, OAuthErrorCode, requireBearerAuth, } from "@modelcontextprotocol/server";
4
+ import { TokenExchangeFailed, } from "../credentials/token-exchange.js";
5
+ import { exchangedTokenKey } from "../server.js";
6
+ /**
7
+ * Serves the gateway over streamable HTTP, behind a bearer gate when token exchange is configured.
8
+ * Without it the server has no caller to authenticate; the config then only accepts a loopback
9
+ * host, so the operator's credentials are not offered to the network.
10
+ *
11
+ * Guard: the gate's verifier is the token exchange itself. The gateway cannot check a token it
12
+ * holds no key for, and the authorization server that can — it issued the token — does so while
13
+ * exchanging it; a refused exchange is `invalid_token`, so the client is told to re-authorize
14
+ * rather than handed a tool error it cannot act on. The caller's token never leaves this process
15
+ * except to that authorization server.
16
+ */
17
+ export function serveHttp(factory, exchange, options) {
18
+ const resource = new URL(options.resource);
19
+ const metadataUrl = getOAuthProtectedResourceMetadataUrl(resource);
20
+ const metadataPath = new URL(metadataUrl).pathname;
21
+ const metadata = JSON.stringify({
22
+ resource: resource.href,
23
+ authorization_servers: options.authorizationServers,
24
+ bearer_methods_supported: ["header"],
25
+ });
26
+ const handler = createMcpHandler(factory);
27
+ const gate = exchange === undefined
28
+ ? undefined
29
+ : requireBearerAuth({
30
+ resourceMetadataUrl: metadataUrl,
31
+ verifier: {
32
+ async verifyAccessToken(token) {
33
+ try {
34
+ const exchanged = await exchange.exchange(token);
35
+ return {
36
+ token,
37
+ clientId: "",
38
+ scopes: [],
39
+ expiresAt: exchanged.expiresAt,
40
+ resource,
41
+ extra: { [exchangedTokenKey]: exchanged.token },
42
+ };
43
+ }
44
+ catch (error) {
45
+ if (error instanceof TokenExchangeFailed && error.rejected) {
46
+ throw new OAuthError(OAuthErrorCode.InvalidToken, "token rejected");
47
+ }
48
+ throw new OAuthError(OAuthErrorCode.ServerError, "the authorization server is unavailable");
49
+ }
50
+ },
51
+ },
52
+ });
53
+ const hostnames = [
54
+ ...localhostAllowedHostnames(),
55
+ ...options.allowedHostnames,
56
+ ];
57
+ const node = toNodeHandler({
58
+ async fetch(request) {
59
+ const rejected = hostHeaderValidationResponse(request, hostnames);
60
+ if (rejected !== undefined) {
61
+ return rejected;
62
+ }
63
+ const { pathname } = new URL(request.url);
64
+ if (pathname === metadataPath) {
65
+ return new Response(metadata, {
66
+ headers: { "content-type": "application/json" },
67
+ });
68
+ }
69
+ if (pathname !== options.path) {
70
+ return new Response(null, { status: 404 });
71
+ }
72
+ if (gate === undefined) {
73
+ return handler.fetch(request);
74
+ }
75
+ const auth = await gate(request);
76
+ if (auth instanceof Response) {
77
+ return auth;
78
+ }
79
+ return handler.fetch(request, { authInfo: auth });
80
+ },
81
+ });
82
+ const server = createServer((req, res) => {
83
+ void node(req, res);
84
+ });
85
+ return new Promise((resolve) => {
86
+ server.listen(options.port, options.host, () => resolve(server));
87
+ });
88
+ }
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
1
  {
2
2
  "name": "@sezzlee/openapi-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "MCP server that exposes an OpenAPI document as a search-first sezzlee catalog over a remote backend.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "mcp-server",
9
+ "sezzlee",
10
+ "openapi",
11
+ "swagger",
12
+ "api-gateway",
13
+ "agents"
14
+ ],
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/sezzlee/mcp.git",
19
+ "directory": "packages/servers/openapi-mcp"
20
+ },
21
+ "homepage": "https://github.com/sezzlee/mcp/tree/main/packages/servers/openapi-mcp#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/sezzlee/mcp/issues"
24
+ },
25
+ "type": "module",
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "main": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "bin": {
44
+ "sezzlee-openapi": "./dist/cli.js"
45
+ },
46
+ "dependencies": {
47
+ "@modelcontextprotocol/node": "^2.0.0",
48
+ "@modelcontextprotocol/server": "^2.0.0",
49
+ "@sezzlee/core": "^0.1.0",
50
+ "@sezzlee/openapi": "^0.1.0",
51
+ "zod": "^4.5.4"
52
+ },
53
+ "devDependencies": {
54
+ "@modelcontextprotocol/client": "^2.0.0",
55
+ "@sezzlee/oxlint-config": "0.0.0",
56
+ "@sezzlee/typescript-config": "0.0.0",
57
+ "@types/node": "^24.3.0",
58
+ "oxlint": "1.85.0",
59
+ "typescript": "7.0.2",
60
+ "vitest": "^3.2.4"
61
+ },
62
+ "scripts": {
63
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
64
+ "dev": "tsc --watch",
65
+ "lint": "oxlint --deny-warnings",
66
+ "check-types": "tsc -p tsconfig.test.json",
67
+ "test": "vitest run"
68
+ }
6
69
  }