@procedures/mcp-server 0.10.0 → 0.12.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/src/face.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The MCP face of a procedures host (CLI-7): Streamable HTTP at a path of a listener of the host.
3
+ *
4
+ * `mcpFace(options)` gives a face factory for `createProcedureHost({ faces: { mcp: mcpFace() } })`
5
+ * of `@procedures/peer`. The face mounts on the mount list of the listener, so the MCP endpoint
6
+ * shares the port of the other faces. Each tool call goes through the handler of the face, thus
7
+ * through the request tracking of the service of the host.
8
+ *
9
+ * - The tools are the procedures of the registry of the host that its policy exposes.
10
+ * - Without a token of its own, the face takes the token of the policy. Without origins of its
11
+ * own, it takes the permitted origins of the policy.
12
+ * - The types are structural: this package does not depend on `@procedures/peer`.
13
+ */
14
+
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
16
+ import type { HostPolicy, ProcedureRegistry, RequestHandler } from "@procedures/client";
17
+ import type { McpToolFilter } from "@procedures/mcp";
18
+ import { McpServerTransport } from "./transport/mcp-transport.js";
19
+ import type { McpServerTransportOptions } from "./types.js";
20
+
21
+ /** The options of `mcpFace()`: the options of an MCP transport, with the path of its endpoint. */
22
+ export interface McpFaceOptions extends Omit<McpServerTransportOptions, "transport" | "httpOptions" | "sdkTransport" | "stdio" | "sseOptions" | "registry"> {
23
+ /** The path of the endpoint. The default is `/mcp`. */
24
+ readonly path?: string | undefined;
25
+ /** The token that each request sends as `Authorization: Bearer <token>`. The default is the token of the policy. */
26
+ readonly token?: string | undefined;
27
+ /** The browser origins that can call the endpoint, besides loopback origins. The default is the list of the policy. */
28
+ readonly allowedOrigins?: string[] | undefined;
29
+ }
30
+
31
+ /** What the MCP face needs of its host: the handler of its calls, the registry and the policy. */
32
+ export interface McpFaceContext {
33
+ readonly handler: RequestHandler;
34
+ readonly registry: ProcedureRegistry;
35
+ readonly policy: HostPolicy;
36
+ }
37
+
38
+ /** The MCP face: its request handler, its stop parts, its endpoint and its transport. */
39
+ export interface McpFace {
40
+ readonly handler: (req: IncomingMessage, res: ServerResponse) => boolean;
41
+ readonly parts: {
42
+ start(): Promise<void>;
43
+ stopIntake(): void;
44
+ drain(): Promise<void>;
45
+ inFlight(): number;
46
+ close(): Promise<void>;
47
+ };
48
+ readonly endpoint: { readonly type: string; readonly scheme: "http"; readonly path: string };
49
+ readonly transport: McpServerTransport;
50
+ }
51
+
52
+ /**
53
+ * This function gives the MCP face of a procedures host. The host calls the factory one time.
54
+ *
55
+ * @example
56
+ * ```typescript
57
+ * const host = createProcedureHost({ registry, policy, faces: { http: true, mcp: mcpFace({ serverInfo }) }, listen });
58
+ * ```
59
+ */
60
+ export function mcpFace(options: McpFaceOptions = {}): (context: McpFaceContext) => McpFace {
61
+ return (context) => {
62
+ const { path: givenPath, token: givenToken, allowedOrigins: givenOrigins, ...common } = options;
63
+ const path = givenPath ?? "/mcp";
64
+ const token = givenToken ?? context.policy.token;
65
+ const policyOrigins = context.policy.browser.allowedOrigins;
66
+ const allowedOrigins = givenOrigins ?? (Array.isArray(policyOrigins) ? [...policyOrigins] : undefined);
67
+ const toolFilter: McpToolFilter = { ...common.toolFilter, ...(context.policy.expose ? { include: context.policy.expose } : {}) };
68
+ const transport = new McpServerTransport(context.handler, {
69
+ ...common,
70
+ registry: context.registry,
71
+ toolFilter,
72
+ transport: "http",
73
+ httpOptions: {
74
+ mount: true,
75
+ path,
76
+ ...(token !== undefined ? { token } : {}),
77
+ ...(allowedOrigins ? { allowedOrigins } : {}),
78
+ ...(context.handler.ambientCredentials ? { ambientCredentials: context.handler.ambientCredentials } : {}),
79
+ },
80
+ });
81
+ return {
82
+ handler: (req, res) => transport.handleHttp(req, res),
83
+ // The tool calls go through the request tracking of the host: the face counts no work of its own.
84
+ // An open stream of a session ends at the close of the face.
85
+ parts: {
86
+ start: () => transport.start(),
87
+ stopIntake: () => undefined,
88
+ drain: async () => undefined,
89
+ inFlight: () => 0,
90
+ close: () => transport.stop(),
91
+ },
92
+ endpoint: { type: "mcp", scheme: "http", path },
93
+ transport,
94
+ };
95
+ };
96
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,10 @@ export { createMcpHost, mcpExposeRule, redirectConsoleToStderr } from "./host.js
20
20
  export type { McpHost, McpHostOptions, McpHostFaces, McpStdioFace, McpHostServerOptions, McpHostParts, McpHostState } from "./host.js";
21
21
  export { DEFAULT_ELICIT_TIMEOUT_MS } from "./transport/mcp-transport.js";
22
22
 
23
+ // The MCP face of a procedures host (CLI-7): Streamable HTTP on a listener of the host
24
+ export { mcpFace } from "./face.js";
25
+ export type { McpFace, McpFaceContext, McpFaceOptions } from "./face.js";
26
+
23
27
  // The envelopes of the text from outside the host (CLI-10)
24
28
  export { createUntrustedEnvelopes, defaultUntrustedNotice, DEFAULT_UNTRUSTED_TAG } from "./untrusted.js";
25
29
  export type { UntrustedContentOptions, UntrustedWrapContext, UntrustedEnvelopes } from "./untrusted.js";
@@ -23,8 +23,13 @@ import type { HttpTransportOptions } from "../types.js";
23
23
 
24
24
  /** The HTTP endpoint of an MCP server, with a session for each client. */
25
25
  export interface McpHttpTransport {
26
- /** This function starts to listen, when the endpoint has a server of its own. It gives the URL. */
26
+ /** This function starts to listen, when the endpoint has a server of its own. It gives the URL, or the path of a mounted endpoint. */
27
27
  listen(): Promise<string>;
28
+ /**
29
+ * This function answers a request of the path of the endpoint and gives true. It gives false for
30
+ * another path, when the endpoint does not own its server.
31
+ */
32
+ handle(req: IncomingMessage, res: ServerResponse): boolean;
28
33
  /** The number of open sessions. */
29
34
  readonly sessions: number;
30
35
  /** This function closes each session, and the server when the endpoint made one. */
@@ -41,8 +46,9 @@ export function createHttpTransport(
41
46
  ): McpHttpTransport {
42
47
  const path = options.path ?? "/mcp";
43
48
  const host = options.host ?? "127.0.0.1";
44
- const owned = options.server === undefined;
45
- const server: HttpServer = options.server ?? createServer();
49
+ const mounted = options.mount === true;
50
+ const owned = !mounted && options.server === undefined;
51
+ const server: HttpServer | undefined = mounted ? undefined : (options.server ?? createServer());
46
52
  const sessions = new Map<string, StreamableHTTPServerTransport>();
47
53
 
48
54
  const refuse = (res: ServerResponse, status: number, code: string, message: string): void => {
@@ -69,11 +75,12 @@ export function createHttpTransport(
69
75
  return transport;
70
76
  };
71
77
 
72
- const onRequest = (req: IncomingMessage, res: ServerResponse): void => {
78
+ const handle = (req: IncomingMessage, res: ServerResponse): boolean => {
73
79
  const url = new URL(req.url ?? "/", "http://localhost");
74
80
  if (url.pathname !== path) {
75
- if (owned) refuse(res, 404, "NOT_FOUND", `No MCP endpoint at ${url.pathname}`);
76
- return;
81
+ if (!owned) return false;
82
+ refuse(res, 404, "NOT_FOUND", `No MCP endpoint at ${url.pathname}`);
83
+ return true;
77
84
  }
78
85
  // A web page must not reach the tools: the host and the origin of the request. A server that
79
86
  // reads cookies trusts a request with ambient credentials only from a named origin (CSRF)
@@ -81,11 +88,11 @@ export function createHttpTransport(
81
88
  const guard = checkBrowserRequest(req, { ...(options.allowedOrigins ? { allowedOrigins: options.allowedOrigins } : {}) }, false, ambient);
82
89
  if (!guard.ok) {
83
90
  refuse(res, guard.status, guard.code, guard.message);
84
- return;
91
+ return true;
85
92
  }
86
93
  if (options.token !== undefined && !tokenMatches(options.token, bearerOf(req.headers["authorization"]))) {
87
94
  refuse(res, 401, "UNAUTHORIZED", "Unauthorized: this server requires its token");
88
- return;
95
+ return true;
89
96
  }
90
97
  void transportOf(req)
91
98
  .then((transport) => {
@@ -98,11 +105,17 @@ export function createHttpTransport(
98
105
  .catch((error: unknown) => {
99
106
  if (!res.headersSent) refuse(res, 500, "INTERNAL_ERROR", error instanceof Error ? error.message : String(error));
100
107
  });
108
+ return true;
109
+ };
110
+ const onRequest = (req: IncomingMessage, res: ServerResponse): void => {
111
+ handle(req, res);
101
112
  };
102
- server.on("request", onRequest);
113
+ server?.on("request", onRequest);
103
114
 
104
115
  return {
116
+ handle,
105
117
  listen: async () => {
118
+ if (!server) return path;
106
119
  if (owned && !server.listening) {
107
120
  await new Promise<void>((resolve, reject) => {
108
121
  server.once("error", reject);
@@ -120,11 +133,11 @@ export function createHttpTransport(
120
133
  return sessions.size;
121
134
  },
122
135
  close: async () => {
123
- server.off("request", onRequest);
136
+ server?.off("request", onRequest);
124
137
  const open = [...sessions.values()];
125
138
  sessions.clear();
126
139
  await Promise.all(open.map((transport) => transport.close().catch(() => undefined)));
127
- if (owned && server.listening) {
140
+ if (owned && server?.listening) {
128
141
  await new Promise<void>((resolve) => {
129
142
  server.close(() => resolve());
130
143
  server.closeAllConnections();
@@ -12,9 +12,9 @@ import {
12
12
  type ServerNotification,
13
13
  } from "@modelcontextprotocol/sdk/types.js";
14
14
  import type { Transport as SdkTransport } from "@modelcontextprotocol/sdk/shared/transport.js";
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
15
16
  import type {
16
17
  ServerTransport,
17
- Server,
18
18
  ProcedureRegistry,
19
19
  RequestHandler,
20
20
  Credentials,
@@ -156,7 +156,8 @@ export class McpServerTransport implements ServerTransport {
156
156
  readonly name = "mcp";
157
157
 
158
158
  private mcpServer: McpServer;
159
- private server: Server;
159
+ /** The handler of the calls: a server, or the handler of a face of a procedures host (CLI-7). */
160
+ private server: RequestHandler;
160
161
  private registry: ProcedureRegistry;
161
162
  private options: Required<Pick<McpServerTransportOptions, "transport" | "serverInfo">> &
162
163
  McpServerTransportOptions;
@@ -176,7 +177,7 @@ export class McpServerTransport implements ServerTransport {
176
177
  /** This function removes the listener of the end of the stdio input */
177
178
  private stdinEnd: (() => void) | undefined;
178
179
 
179
- constructor(server: Server, options: McpServerTransportOptions = {}) {
180
+ constructor(server: RequestHandler, options: McpServerTransportOptions = {}) {
180
181
  this.server = server;
181
182
  this.options = {
182
183
  transport: options.transport ?? "stdio",
@@ -671,6 +672,14 @@ export class McpServerTransport implements ServerTransport {
671
672
  this.log("MCP server stopped");
672
673
  }
673
674
 
675
+ /**
676
+ * This method answers a request of the Streamable HTTP endpoint on a listener that another part
677
+ * owns (`httpOptions.mount`, CLI-7). It gives false for another path, and before `start()`.
678
+ */
679
+ handleHttp(req: IncomingMessage, res: ServerResponse): boolean {
680
+ return this.http?.handle(req, res) ?? false;
681
+ }
682
+
674
683
  /**
675
684
  * Check if the transport is running.
676
685
  */
package/src/types.ts CHANGED
@@ -65,6 +65,11 @@ export interface HttpTransportOptions {
65
65
  allowedOrigins?: string[] | undefined;
66
66
  /** An HTTP server to answer on, in place of a server of the transport (it does not listen then) */
67
67
  server?: import("node:http").Server | undefined;
68
+ /**
69
+ * True: the endpoint has no server and attaches to none. The owner of a listener mounts its
70
+ * request handler (`McpServerTransport.handleHttp`), for example the procedures host (CLI-7).
71
+ */
72
+ mount?: boolean | undefined;
68
73
  /**
69
74
  * How the procedure server reads cookies (`ambientCredentials` of `ProcedureServer`). The
70
75
  * transport sets it from its server. With a value, a request with a cookie needs a named origin.