@procedures/client 0.26.1 → 0.27.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.
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { Server, type ServerOptions } from "./server.js";
9
- import type { AmbientCredentials, Credentials, Method, Principal, PrincipalOf, RequestIdentity, ServerHandler, ServerRequest, ServerResponse } from "./types.js";
9
+ import type { Admission, AmbientCredentials, Credentials, Method, Principal, PrincipalOf, RequestIdentity, ServerHandler, ServerRequest, ServerResponse } from "./types.js";
10
10
  import { methodToPath, pathToMethod } from "./method.js";
11
11
  import { isLocalCredentials } from "./request-auth.js";
12
12
  import type {
@@ -149,6 +149,13 @@ export interface ProcedureServerOptions extends ServerOptions {
149
149
  * `UNAUTHORIZED`. It runs after the token check. The capability function can read
150
150
  * `request.principal`. Like the request hook, it can run more than one time for one request or
151
151
  * one task: `admits()` and `identify()` run it too (roadmap CLI-69).
152
+ *
153
+ * On the MCP HTTP face, it runs one time for each HTTP request, in `admission()` (CLI-2b). The
154
+ * tool calls of the request use that admission, so the function does not run for each call. For
155
+ * `admission()`, `admits()` and `identify()`, the request has an empty method
156
+ * (`{ service: "", operation: "" }`), the id `admission`, `connection` or `identify`, no payload
157
+ * and no metadata. Thus the function must not branch on the method, the payload, the metadata
158
+ * or the transport of the request. It reads only the credentials and the principal.
152
159
  */
153
160
  authenticate?: ((request: ServerRequest<unknown>) => Principal | undefined | Promise<Principal | undefined>) | undefined;
154
161
 
@@ -319,7 +326,7 @@ async function* forgottenAtRetryableEnd<T>(items: AsyncIterable<T>, forget: () =
319
326
  }
320
327
  }
321
328
 
322
- /** The request of credentials without a call: `admits()` and `identify()` give it to the checks of each call. */
329
+ /** The request of credentials without a call: `admits()`, `identify()` and `admission()` give it to the checks of each call. */
323
330
  function credentialsRequest(id: string, credentials: Credentials): ServerRequest<unknown> {
324
331
  return { id, method: { service: "", operation: "" }, payload: undefined, metadata: {}, credentials };
325
332
  }
@@ -478,6 +485,11 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
478
485
  private readonly token: string | undefined;
479
486
  private readonly principalOf: PrincipalOf | undefined;
480
487
  private readonly authenticate: ProcedureServerOptions["authenticate"];
488
+ /**
489
+ * The admissions of this server (CLI-2b): the identity of each object of admitted credentials
490
+ * that `admission()` gave. Only this server adds an entry, so a request cannot claim one.
491
+ */
492
+ private readonly admissions = new WeakMap<Credentials, { readonly principal: Principal | undefined; readonly narrowing: ServerRequest<unknown>["narrowing"] }>();
481
493
  /** The functions that `drain()` waits on: each one runs when no call is in flight */
482
494
  private idleWaiters: Array<() => void> = [];
483
495
  /** The publisher of the remote events, from the option `events`, or undefined. */
@@ -921,8 +933,9 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
921
933
  * network request must present the token. Only a local request (`LOCAL_CREDENTIALS`) skips the
922
934
  * hook and the token. A request without credentials is not local: a server with a token or a
923
935
  * request hook refuses it, and the hook does not run (CLI-1). The authenticate function gives
924
- * the principal. The function gives an UNAUTHORIZED response, or undefined when the request
925
- * passes.
936
+ * the principal. The credentials of an admission of this server (`admission()`) skip the
937
+ * checks: the request gets the principal and the narrowing of that admission (CLI-2b). The
938
+ * function gives an UNAUTHORIZED response, or undefined when the request passes.
926
939
  */
927
940
  private async admit(request: ServerRequest<unknown>): Promise<ServerResponse<unknown> | undefined> {
928
941
  const unauthorized = (message: string): ServerResponse<unknown> => ({
@@ -930,6 +943,17 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
930
943
  status: { type: "error", code: "UNAUTHORIZED", message, retryable: false },
931
944
  metadata: {},
932
945
  });
946
+ // A face admitted the network request of this call one time (CLI-2b): the checks do not run
947
+ // again. Only this server makes such credentials, so a copy or a forgery has no entry.
948
+ const admitted = request.credentials !== undefined ? this.admissions.get(request.credentials) : undefined;
949
+ if (admitted) {
950
+ if (admitted.principal) request.principal = admitted.principal;
951
+ if (admitted.narrowing) {
952
+ const order = this.capabilityOptions.clearanceOrder;
953
+ request.narrowing = request.narrowing ? joinNarrowing(request.narrowing, admitted.narrowing, order) : admitted.narrowing;
954
+ }
955
+ return undefined;
956
+ }
933
957
  // Only a local request skips the token and the request hook (CLI-1). A face that sets no
934
958
  // credentials skips neither: the server refuses it, and the hook does not run. Before, such a
935
959
  // request passed as a request of this process.
@@ -1034,6 +1058,25 @@ export class ProcedureServer extends Server implements RepositoryProvider, StopP
1034
1058
  return (await this.admit(request)) === undefined ? request.principal : undefined;
1035
1059
  }
1036
1060
 
1061
+ /**
1062
+ * This method admits a network request one time, before its calls (CLI-2b). It runs the checks
1063
+ * of each call: the request hook, the token check and the authenticate function. The admitted
1064
+ * credentials are a new object. A call with them gets the principal and the narrowing of the
1065
+ * admission, and the checks do not run again for it. Only the admitted object skips the checks:
1066
+ * a copy of it and the credentials of another server go through them. A face gives the
1067
+ * admitted credentials only to the calls of the same network request.
1068
+ */
1069
+ async admission(credentials: Credentials): Promise<Admission> {
1070
+ const request = credentialsRequest("admission", credentials);
1071
+ const refusal = await this.admit(request);
1072
+ if (refusal) {
1073
+ return { ok: false, code: "UNAUTHORIZED", message: refusal.status.type === "error" ? refusal.status.message : "Unauthorized" };
1074
+ }
1075
+ const admitted: Credentials = Object.freeze({ ...credentials });
1076
+ this.admissions.set(admitted, { principal: request.principal, narrowing: request.narrowing });
1077
+ return { ok: true, credentials: admitted, ...(request.principal ? { principal: request.principal } : {}) };
1078
+ }
1079
+
1037
1080
  /**
1038
1081
  * Run a keyed call one time. The run continues when its caller goes away, so that the next try
1039
1082
  * gets its result: only the deadline of the call and the stop of the server end it. A stream
@@ -124,8 +124,16 @@ export interface RequestIdentity {
124
124
  * with `UNAUTHORIZED`. The hook runs before the token check and before `authenticate`.
125
125
  *
126
126
  * The hook can run more than one time for one request or one task. `admits()` runs it at a
127
- * WebSocket upgrade, and `identify()` runs it before the MCP face makes a task (roadmap CLI-69).
128
- * Thus a hook with a side effect, for example a count or a log line, sees each of these runs.
127
+ * WebSocket upgrade, and then each call over the connection runs it again. Thus a hook with a
128
+ * side effect, for example a count or a log line, sees each of these runs.
129
+ *
130
+ * On the MCP HTTP face, the hook runs one time for each HTTP request, before its calls (CLI-2b):
131
+ * `admission()` runs it for `initialize`, a tool call, the GET stream and the DELETE of a session.
132
+ * A tool call, and `identify()` before a task (CLI-69), use that admission: the hook does not run
133
+ * again for them. For `admission()`, `admits()` and `identify()`, the request of the hook has an
134
+ * empty method (`{ service: "", operation: "" }`), the id `admission`, `connection` or `identify`,
135
+ * no payload and no metadata. Thus a hook must not branch on the method, the payload, the
136
+ * metadata or the transport of the request. It reads only the credentials.
129
137
  */
130
138
  export type PrincipalOf = (
131
139
  credentials: Credentials,
@@ -180,8 +188,26 @@ export interface RequestHandler {
180
188
  * only in the call.
181
189
  */
182
190
  identify?(credentials: Credentials): Promise<Principal | undefined>;
191
+ /**
192
+ * This function admits a network request one time, before its calls (CLI-2b). It uses the
193
+ * checks of each call: the request hook, the token check and the authenticate function. A face
194
+ * that carries the calls of one network request (MCP over Streamable HTTP) gives the admitted
195
+ * credentials to those calls, so the checks run one time for the request. `ProcedureServer` has
196
+ * it. Absent: the handler examines each call, and the face refuses no request before its calls.
197
+ */
198
+ admission?(credentials: Credentials): Promise<Admission>;
183
199
  }
184
200
 
201
+ /**
202
+ * The result of `admission()` of a request handler (CLI-2b). A request that passes gets its
203
+ * admitted credentials and its principal. Each call of that request with the admitted credentials
204
+ * gets the principal and the narrowing of the admission, and the checks do not run again. A
205
+ * refused request gets `UNAUTHORIZED` and the message of the refusal.
206
+ */
207
+ export type Admission =
208
+ | { readonly ok: true; readonly credentials: Credentials; readonly principal?: Principal | undefined }
209
+ | { readonly ok: false; readonly code: "UNAUTHORIZED"; readonly message: string };
210
+
185
211
  /**
186
212
  * How a server can authenticate a request with ambient credentials (cookies):
187
213
  * - `"always"`: each network request. The request hook `principalOf` reads the cookies, so a face