@emseepea/server 0.3.3 → 0.4.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/dist/index.js CHANGED
@@ -9,7 +9,8 @@ import { isIP } from "node:net";
9
9
  import { join } from "node:path";
10
10
  import { fileURLToPath, pathToFileURL } from "node:url";
11
11
  import { z } from "zod";
12
- import { installRequestTelemetry } from "./telemetry.js";
12
+ import { installObservability, } from "./telemetry.js";
13
+ export { openTelemetry, structuredLogging, } from "./telemetry.js";
13
14
  export * from "./ui.js";
14
15
  export const inputRequired = Object.assign((spec) => sdkInputRequired({ inputRequests: spec.inputRequests }), {
15
16
  elicit: sdkInputRequired.elicit,
@@ -34,7 +35,9 @@ const RESOURCE_KIND = Symbol("resourceKind");
34
35
  const RESOURCE_MATCHES = Symbol("resourceMatches");
35
36
  const RESOURCE_ROUTE = Symbol("resourceRoute");
36
37
  const RESOURCE_LISTING = Symbol("resourceListing");
38
+ const RESOURCE_ACCESS = Symbol("resourceAccess");
37
39
  const PROMPT_NAME = Symbol("promptName");
40
+ const PROMPT_ACCESS = Symbol("promptAccess");
38
41
  const HAS_COMPLETION = Symbol("hasCompletion");
39
42
  const PROMPT_LISTING = Symbol("promptListing");
40
43
  const CACHEABLE_METHODS = [
@@ -86,6 +89,7 @@ export function defineMappedTool(definition) {
86
89
  export function defineResource(definition) {
87
90
  const { name, handler } = definition;
88
91
  assertRegistrationName("Resource", name);
92
+ const access = normalizeCapabilityAccess("Resource", definition.access, definition.requiredScopes);
89
93
  const uri = canonicalResourceUri(definition.uri);
90
94
  const listing = checkedProtocolValue("Resource", {
91
95
  name,
@@ -96,7 +100,7 @@ export function defineResource(definition) {
96
100
  icons: definition.icons,
97
101
  annotations: definition.annotations,
98
102
  size: definition.size,
99
- _meta: definition._meta,
103
+ _meta: accessMetadata(definition._meta, access),
100
104
  });
101
105
  const metadata = Object.freeze({
102
106
  title: listing.title,
@@ -114,6 +118,7 @@ export function defineResource(definition) {
114
118
  [RESOURCE_NAME]: name,
115
119
  [RESOURCE_URI]: uri,
116
120
  [RESOURCE_KIND]: "static",
121
+ [RESOURCE_ACCESS]: access,
117
122
  [RESOURCE_LISTING]: Object.freeze({
118
123
  method: "resources/list",
119
124
  value: listing,
@@ -128,6 +133,7 @@ export function defineResource(definition) {
128
133
  const result = await handler({
129
134
  signal,
130
135
  deadlineMs,
136
+ principal: access === "public" ? undefined : principalFrom(context.http?.authInfo),
131
137
  inputResponses: checkedInputResponses(context.mcpReq.inputResponses),
132
138
  });
133
139
  signal.throwIfAborted();
@@ -156,6 +162,7 @@ export function defineResource(definition) {
156
162
  export function defineResourceTemplate(definition) {
157
163
  const { name, handler } = definition;
158
164
  assertRegistrationName("Resource template", name);
165
+ const access = normalizeCapabilityAccess("Resource template", definition.access, definition.requiredScopes);
159
166
  const { template, route } = checkedResourceTemplate(definition.uriTemplate);
160
167
  const uriTemplate = template.uriTemplate.toString();
161
168
  const listing = checkedProtocolValue("ResourceTemplate", {
@@ -166,7 +173,7 @@ export function defineResourceTemplate(definition) {
166
173
  mimeType: definition.mimeType,
167
174
  icons: definition.icons,
168
175
  annotations: definition.annotations,
169
- _meta: definition._meta,
176
+ _meta: accessMetadata(definition._meta, access),
170
177
  });
171
178
  const metadata = Object.freeze({
172
179
  title: listing.title,
@@ -185,6 +192,7 @@ export function defineResourceTemplate(definition) {
185
192
  [RESOURCE_NAME]: name,
186
193
  [RESOURCE_URI]: uriTemplate,
187
194
  [RESOURCE_KIND]: "template",
195
+ [RESOURCE_ACCESS]: access,
188
196
  [RESOURCE_MATCHES]: (uri) => template.uriTemplate.match(uri) !== null,
189
197
  [RESOURCE_ROUTE]: route,
190
198
  [RESOURCE_LISTING]: Object.freeze({
@@ -210,6 +218,7 @@ export function defineResourceTemplate(definition) {
210
218
  const result = await handler({ uri: requestedUri.href, variables }, {
211
219
  signal,
212
220
  deadlineMs,
221
+ principal: access === "public" ? undefined : principalFrom(context.http?.authInfo),
213
222
  inputResponses: checkedInputResponses(context.mcpReq.inputResponses),
214
223
  });
215
224
  signal.throwIfAborted();
@@ -239,6 +248,7 @@ export function defineResourceTemplate(definition) {
239
248
  export function definePrompt(definition) {
240
249
  const { name, argsSchema, handler } = definition;
241
250
  assertRegistrationName("Prompt", name);
251
+ const access = normalizeCapabilityAccess("Prompt", definition.access, definition.requiredScopes);
242
252
  const argumentNames = Object.keys(argsSchema.shape);
243
253
  const completions = checkedCompletionHandlers("Prompt", definition.complete, argumentNames);
244
254
  const listing = checkedProtocolValue("Prompt", {
@@ -246,7 +256,7 @@ export function definePrompt(definition) {
246
256
  title: definition.title,
247
257
  description: definition.description,
248
258
  icons: definition.icons,
249
- _meta: definition._meta,
259
+ _meta: accessMetadata(definition._meta, access),
250
260
  arguments: promptArguments(argsSchema),
251
261
  });
252
262
  const metadata = Object.freeze({
@@ -257,6 +267,7 @@ export function definePrompt(definition) {
257
267
  });
258
268
  const registration = {
259
269
  [PROMPT_NAME]: name,
270
+ [PROMPT_ACCESS]: access,
260
271
  [HAS_COMPLETION]: completions.size > 0,
261
272
  [PROMPT_LISTING]: listing,
262
273
  [REGISTER](server, timeoutMs, maxApplicationResultBytes) {
@@ -274,6 +285,7 @@ export function definePrompt(definition) {
274
285
  const result = await handler(parsedArgs.data, {
275
286
  signal,
276
287
  deadlineMs,
288
+ principal: access === "public" ? undefined : principalFrom(context.http?.authInfo),
277
289
  inputResponses: checkedInputResponses(context.mcpReq.inputResponses),
278
290
  });
279
291
  signal.throwIfAborted();
@@ -420,10 +432,7 @@ function createCheckedTool(definition, execute, streaming, allowsInputRequired)
420
432
  const { name, inputSchema, outputSchema } = definition;
421
433
  assertRegistrationName("Tool", name);
422
434
  assertValidMcpHeaderAnnotations(inputSchema);
423
- const access = normalizeToolAccess(definition.access, definition.requiredScopes);
424
- const publicAccess = access === "public"
425
- ? Object.freeze({ type: "public" })
426
- : Object.freeze({ type: "protected", requiredScopes: [...access.requiredScopes] });
435
+ const access = normalizeCapabilityAccess("Tool", definition.access, definition.requiredScopes);
427
436
  const sdkInputSchema = sdkMetadataSchema(inputSchema);
428
437
  const sdkOutputSchema = sdkMetadataSchema(outputSchema);
429
438
  const listing = checkedProtocolValue("Tool", {
@@ -434,10 +443,7 @@ function createCheckedTool(definition, execute, streaming, allowsInputRequired)
434
443
  annotations: definition.annotations,
435
444
  inputSchema: jsonMetadataSchema(inputSchema, "input"),
436
445
  outputSchema: jsonMetadataSchema(outputSchema, "output"),
437
- _meta: {
438
- ...definition._meta,
439
- "io.emseepea/access": publicAccess,
440
- },
446
+ _meta: accessMetadata(definition._meta, access),
441
447
  });
442
448
  const metadata = Object.freeze({
443
449
  title: listing.title,
@@ -566,12 +572,24 @@ function progressReporter(context, signal, maxEvents, maxEventBytes) {
566
572
  },
567
573
  async finish() {
568
574
  closed = true;
569
- await Promise.allSettled([...pending]);
575
+ await Promise.allSettled(pending);
570
576
  },
571
577
  throwIfFailed() { if (failure)
572
578
  throw failure; },
573
579
  };
574
580
  }
581
+ function catalogueListings(tools, resources, prompts) {
582
+ return new Map([
583
+ ["tools/list", tools.map((tool) => tool[TOOL_LISTING])],
584
+ ["resources/list", resources
585
+ .filter((resource) => resource[RESOURCE_LISTING].method === "resources/list")
586
+ .map((resource) => resource[RESOURCE_LISTING].value)],
587
+ ["resources/templates/list", resources
588
+ .filter((resource) => resource[RESOURCE_LISTING].method === "resources/templates/list")
589
+ .map((resource) => resource[RESOURCE_LISTING].value)],
590
+ ["prompts/list", prompts.map((prompt) => prompt[PROMPT_LISTING])],
591
+ ]);
592
+ }
575
593
  function normalizeListPagination(options) {
576
594
  const pageSize = positiveInteger("listPagination.pageSize", options.pageSize);
577
595
  if (pageSize > 100)
@@ -663,9 +681,7 @@ function installListPagination(server, pagination) {
663
681
  server.server.setRequestHandler("prompts/list", (request) => paginationPage(prompts, request.params?.cursor));
664
682
  }
665
683
  export function createEmseepea(options) {
666
- if (options.telemetry !== undefined && typeof options.telemetry !== "boolean") {
667
- throw new TypeError("telemetry must be a boolean");
668
- }
684
+ const observability = normalizeObservability(options.observability);
669
685
  const readiness = options.readiness;
670
686
  const readinessTimeoutMs = callbackTimeout("readiness", readiness, "readinessTimeoutMs", options.readinessTimeoutMs);
671
687
  const stopping = new AbortController();
@@ -714,16 +730,27 @@ export function createEmseepea(options) {
714
730
  const maxProgressEventBytes = positiveInteger("maxProgressEventBytes", options.maxProgressEventBytes ?? 8 * 1024);
715
731
  const operationTimeoutMs = positiveInteger("operationTimeoutMs", options.operationTimeoutMs ?? 30_000);
716
732
  const deployment = normalizeDeployment(options.deployment ?? { mode: "loopback" });
717
- const oauth = options.oauth ? normalizeOAuth(options.oauth) : undefined;
733
+ const authentication = options.authentication
734
+ ? normalizeAuthentication(options.authentication)
735
+ : undefined;
718
736
  const hasStreaming = tools.some((tool) => tool[TOOL_STREAMING]);
719
737
  if (deployment.mode !== "loopback" &&
720
738
  tools.some((tool) => tool[TOOL_STREAMING] && tool[TOOL_ACCESS] !== "public")) {
721
739
  throw new TypeError("Protected streaming tools currently require the loopback deployment profile");
722
740
  }
723
- if (tools.some((tool) => tool[TOOL_ACCESS] !== "public") && !oauth) {
724
- throw new TypeError("Protected tools require OAuth resource-server configuration");
741
+ if ([
742
+ ...tools.map((tool) => tool[TOOL_ACCESS]),
743
+ ...resources.map((resource) => resource[RESOURCE_ACCESS]),
744
+ ...prompts.map((prompt) => prompt[PROMPT_ACCESS]),
745
+ ].some((access) => access !== "public") && !authentication) {
746
+ throw new TypeError("Protected capabilities require authentication configuration");
725
747
  }
726
748
  const toolsByName = new Map(tools.map((tool) => [tool[TOOL_NAME], tool]));
749
+ const resourcesByUri = new Map(resources
750
+ .filter((resource) => resource[RESOURCE_KIND] === "static")
751
+ .map((resource) => [resource[RESOURCE_URI], resource]));
752
+ const resourceTemplates = resources.filter((resource) => resource[RESOURCE_KIND] === "template");
753
+ const promptsByName = new Map(prompts.map((prompt) => [prompt[PROMPT_NAME], prompt]));
727
754
  const enabledMethods = new Set(["server/discover"]);
728
755
  if (tools.length)
729
756
  enabledMethods.add("tools/list").add("tools/call");
@@ -741,37 +768,43 @@ export function createEmseepea(options) {
741
768
  const cacheHints = options.cacheHints === undefined
742
769
  ? undefined
743
770
  : normalizeCacheHints(options.cacheHints, enabledMethods);
744
- const pagination = paginationOptions
745
- ? compileListPagination(paginationOptions, new Map([
746
- ["tools/list", tools.map((tool) => tool[TOOL_LISTING])],
747
- ["resources/list", resources
748
- .filter((resource) => resource[RESOURCE_LISTING].method === "resources/list")
749
- .map((resource) => resource[RESOURCE_LISTING].value)],
750
- ["resources/templates/list", resources
751
- .filter((resource) => resource[RESOURCE_LISTING].method === "resources/templates/list")
752
- .map((resource) => resource[RESOURCE_LISTING].value)],
753
- ["prompts/list", prompts.map((prompt) => prompt[PROMPT_LISTING])],
754
- ]))
771
+ const basePagination = paginationOptions
772
+ ? compileListPagination(paginationOptions, catalogueListings(tools, resources, prompts))
755
773
  : undefined;
756
774
  const sdkHandler = createMcpHandler(() => {
775
+ const request = requestOperations.getStore();
776
+ const activeTools = request?.filterCatalogues
777
+ ? tools.filter((tool) => accessAllows(tool[TOOL_ACCESS], request.principal))
778
+ : tools;
779
+ const activeResources = request?.filterCatalogues
780
+ ? resources.filter((resource) => accessAllows(resource[RESOURCE_ACCESS], request.principal))
781
+ : resources;
782
+ const activePrompts = request?.filterCatalogues
783
+ ? prompts.filter((prompt) => accessAllows(prompt[PROMPT_ACCESS], request.principal))
784
+ : prompts;
785
+ const activeHasCompletion = activeResources.some((resource) => resource[HAS_COMPLETION]) ||
786
+ activePrompts.some((prompt) => prompt[HAS_COMPLETION]);
757
787
  const server = new McpServer(serverInfo, {
758
788
  capabilities: {
759
- ...(tools.length ? { tools: { listChanged: false } } : {}),
760
- ...(resources.length ? { resources: { subscribe: false, listChanged: false } } : {}),
761
- ...(prompts.length ? { prompts: { listChanged: false } } : {}),
762
- ...(hasCompletion ? { completions: {} } : {}),
789
+ ...(activeTools.length ? { tools: { listChanged: false } } : {}),
790
+ ...(activeResources.length ? { resources: { subscribe: false, listChanged: false } } : {}),
791
+ ...(activePrompts.length ? { prompts: { listChanged: false } } : {}),
792
+ ...(activeHasCompletion ? { completions: {} } : {}),
763
793
  },
764
794
  instructions: options.instructions,
765
- cacheHints,
795
+ cacheHints: request?.filterCatalogues ? undefined : cacheHints,
766
796
  supportedProtocolVersions: [PROTOCOL_VERSION],
767
797
  });
768
- for (const tool of tools) {
798
+ for (const tool of activeTools) {
769
799
  tool[REGISTER](server, operationTimeoutMs, maxApplicationResultBytes, maxProgressEvents, maxProgressEventBytes);
770
800
  }
771
- for (const resource of resources)
801
+ for (const resource of activeResources)
772
802
  resource[REGISTER](server, operationTimeoutMs, maxApplicationResultBytes);
773
- for (const prompt of prompts)
803
+ for (const prompt of activePrompts)
774
804
  prompt[REGISTER](server, operationTimeoutMs, maxApplicationResultBytes);
805
+ const pagination = request?.filterCatalogues && paginationOptions
806
+ ? compileListPagination(paginationOptions, catalogueListings(activeTools, activeResources, activePrompts))
807
+ : basePagination;
775
808
  if (pagination)
776
809
  installListPagination(server, pagination);
777
810
  return server;
@@ -782,7 +815,10 @@ export function createEmseepea(options) {
782
815
  });
783
816
  const nodeHandler = toNodeHandler(sdkHandler);
784
817
  const app = createMcpFastifyApp({ host: deployment.mode === "loopback" ? "127.0.0.1" : "0.0.0.0" });
785
- const finishTelemetry = options.telemetry === true ? installRequestTelemetry(app) : undefined;
818
+ const observabilityLimits = observability.length ? { deliveryTimeoutMs: 1_000 } : undefined;
819
+ const finishObservability = observabilityLimits
820
+ ? installObservability(app, observability, (body) => capabilityNameForRequest(body, toolsByName, resourcesByUri, resourceTemplates, promptsByName), observabilityLimits)
821
+ : undefined;
786
822
  const limiter = deployment.mode === "production-behind-proxy"
787
823
  ? new FixedWindowRateLimiter(deployment.rateLimit)
788
824
  : undefined;
@@ -793,11 +829,11 @@ export function createEmseepea(options) {
793
829
  .code(available ? 200 : 503).send(available ? "ready\n" : "not ready\n");
794
830
  });
795
831
  app.addHook("preClose", async () => { stopping.abort(); });
796
- if (oauth) {
832
+ if (authentication) {
797
833
  app.all("/.well-known/*", async (request, reply) => {
798
- const response = oauthMetadataResponse(new Request(new URL(request.url, oauth.metadata.resourceServerUrl.origin), {
834
+ const response = oauthMetadataResponse(new Request(new URL(request.url, authentication.metadata.resourceServerUrl.origin), {
799
835
  method: request.method,
800
- }), oauth.metadata);
836
+ }), authentication.metadata);
801
837
  if (!response) {
802
838
  await reply.code(404).send();
803
839
  return;
@@ -821,20 +857,32 @@ export function createEmseepea(options) {
821
857
  await sendRpcError(reply, 404, -32601, "Method not found", requestId(request.body.id));
822
858
  return;
823
859
  }
824
- const access = protectedAccessForCall(request.body, toolsByName);
825
- if (access && oauth) {
860
+ const access = capabilityAccessForRequest(request.body, toolsByName, resourcesByUri, resourceTemplates, promptsByName);
861
+ const protectsCatalogue = authentication?.discovery === "protected";
862
+ let authInfo;
863
+ if (authentication && (protectsCatalogue || access && access !== "public")) {
826
864
  try {
827
- const authInfo = await verifyProtectedCall(request, reply, access, oauth);
865
+ authInfo = await verifyAuthenticatedRequest(request, reply, protectsCatalogue ? [] : access.requiredScopes, authentication);
828
866
  request.raw.auth = authInfo;
829
867
  }
830
868
  catch (error) {
831
869
  await sendWebResponse(reply, bearerAuthChallengeResponse(safeOAuthError(error), {
832
- requiredScopes: [...access.requiredScopes],
833
- resourceMetadataUrl: oauth.resourceMetadataUrl,
870
+ requiredScopes: protectsCatalogue
871
+ ? []
872
+ : [...access.requiredScopes],
873
+ resourceMetadataUrl: authentication.resourceMetadataUrl,
834
874
  }));
835
875
  return;
836
876
  }
837
877
  }
878
+ const principal = authInfo ? principalFrom(authInfo) : undefined;
879
+ if (protectsCatalogue)
880
+ reply.raw.setHeader("cache-control", "private, no-store");
881
+ if (protectsCatalogue && isCapabilityInvocation(request.body) &&
882
+ (access === undefined || !accessAllows(access, principal))) {
883
+ await sendRpcError(reply, 200, -32602, "Capability not found", requestId(isRecord(request.body) ? request.body.id : undefined));
884
+ return;
885
+ }
838
886
  const disconnected = new AbortController();
839
887
  const abort = () => disconnected.abort();
840
888
  request.raw.once("aborted", abort);
@@ -843,7 +891,12 @@ export function createEmseepea(options) {
843
891
  abort();
844
892
  reply.hijack();
845
893
  try {
846
- await requestOperations.run({ deadlineMs: Date.now() + operationTimeoutMs, signal: disconnected.signal }, () => nodeHandler(request.raw, reply.raw, request.body));
894
+ await requestOperations.run({
895
+ deadlineMs: Date.now() + operationTimeoutMs,
896
+ signal: disconnected.signal,
897
+ principal,
898
+ filterCatalogues: protectsCatalogue,
899
+ }, () => nodeHandler(request.raw, reply.raw, request.body));
847
900
  }
848
901
  finally {
849
902
  request.raw.off("aborted", abort);
@@ -862,7 +915,13 @@ export function createEmseepea(options) {
862
915
  }
863
916
  });
864
917
  app.addHook("onClose", async () => sdkHandler.close());
865
- runtimes.set(app, { deployment, requestTimeoutMs: operationTimeoutMs + 5_000, stopping, finishTelemetry });
918
+ runtimes.set(app, {
919
+ deployment,
920
+ requestTimeoutMs: operationTimeoutMs + 5_000,
921
+ stopping,
922
+ observabilityLimits,
923
+ finishObservability,
924
+ });
866
925
  return app;
867
926
  }
868
927
  function safeOAuthError(error) {
@@ -877,7 +936,7 @@ function safeOAuthError(error) {
877
936
  : "Authorization request failed";
878
937
  return new OAuthError(error.code, message);
879
938
  }
880
- async function verifyProtectedCall(request, reply, access, oauth) {
939
+ async function verifyAuthenticatedRequest(request, reply, requiredScopes, authentication) {
881
940
  const disconnected = new AbortController();
882
941
  const abort = () => disconnected.abort();
883
942
  request.raw.once("aborted", abort);
@@ -885,14 +944,20 @@ async function verifyProtectedCall(request, reply, access, oauth) {
885
944
  if (requestAlreadyClosed(request, reply))
886
945
  abort();
887
946
  try {
888
- const authInfo = await runWithDeadline(disconnected.signal, Date.now() + oauth.verificationTimeoutMs, () => verifyBearerToken(singleHeader(request.raw.rawHeaders, "authorization"), {
889
- verifier: oauth.verifier,
890
- requiredScopes: [...access.requiredScopes],
891
- resourceMetadataUrl: oauth.resourceMetadataUrl,
947
+ const authInfo = await runWithDeadline(disconnected.signal, Date.now() + authentication.verificationTimeoutMs, () => verifyBearerToken(singleHeader(request.raw.rawHeaders, "authorization"), {
948
+ verifier: {
949
+ async verifyAccessToken(token) {
950
+ const authInfo = await authentication.verifier.verifyAccessToken(token);
951
+ principalFrom(authInfo);
952
+ return authInfo;
953
+ },
954
+ },
955
+ requiredScopes: [...requiredScopes],
956
+ resourceMetadataUrl: authentication.resourceMetadataUrl,
892
957
  }));
893
958
  if (!authInfo.resource || authInfo.resource.hash || !checkResourceAllowed({
894
959
  requestedResource: authInfo.resource,
895
- configuredResource: oauth.metadata.resourceServerUrl,
960
+ configuredResource: authentication.metadata.resourceServerUrl,
896
961
  })) {
897
962
  throw new OAuthError(OAuthErrorCode.InvalidToken, "Token is not valid for this resource");
898
963
  }
@@ -912,21 +977,79 @@ async function sendWebResponse(reply, response) {
912
977
  const body = Buffer.from(await response.arrayBuffer());
913
978
  await reply.code(response.status).send(body.length ? body : undefined);
914
979
  }
915
- function protectedAccessForCall(body, toolsByName) {
916
- if (!isRecord(body) || body.method !== "tools/call" || !isRecord(body.params) ||
917
- typeof body.params.name !== "string")
980
+ function capabilityAccessForRequest(body, toolsByName, resourcesByUri, resourceTemplates, promptsByName) {
981
+ if (!isRecord(body) || !isRecord(body.params))
918
982
  return undefined;
919
- const access = toolsByName.get(body.params.name)?.[TOOL_ACCESS];
920
- return access === "public" ? undefined : access;
983
+ const params = body.params;
984
+ if (body.method === "tools/call" && typeof params.name === "string") {
985
+ return toolsByName.get(params.name)?.[TOOL_ACCESS];
986
+ }
987
+ if (body.method === "resources/read" && typeof params.uri === "string") {
988
+ return resourcesByUri.get(params.uri)?.[RESOURCE_ACCESS] ?? resourceTemplates.find((resource) => resource[RESOURCE_MATCHES]?.(params.uri))?.[RESOURCE_ACCESS];
989
+ }
990
+ if (body.method === "prompts/get" && typeof params.name === "string") {
991
+ return promptsByName.get(params.name)?.[PROMPT_ACCESS];
992
+ }
993
+ if (body.method === "completion/complete" && isRecord(params.ref)) {
994
+ const reference = params.ref;
995
+ if (reference.type === "ref/prompt" && typeof reference.name === "string") {
996
+ return promptsByName.get(reference.name)?.[PROMPT_ACCESS];
997
+ }
998
+ if (reference.type === "ref/resource" && typeof reference.uri === "string") {
999
+ return resourceTemplates.find((resource) => resource[RESOURCE_URI] === reference.uri)?.[RESOURCE_ACCESS];
1000
+ }
1001
+ }
1002
+ return undefined;
1003
+ }
1004
+ function isCapabilityInvocation(body) {
1005
+ return isRecord(body) && (body.method === "tools/call" || body.method === "resources/read" ||
1006
+ body.method === "prompts/get" || body.method === "completion/complete");
1007
+ }
1008
+ function capabilityNameForRequest(body, toolsByName, resourcesByUri, resourceTemplates, promptsByName) {
1009
+ if (!isRecord(body) || !isRecord(body.params))
1010
+ return undefined;
1011
+ const params = body.params;
1012
+ if (body.method === "tools/call" && typeof params.name === "string") {
1013
+ return toolsByName.has(params.name) ? params.name : undefined;
1014
+ }
1015
+ if (body.method === "resources/read" && typeof params.uri === "string") {
1016
+ const resource = resourcesByUri.get(params.uri) ?? resourceTemplates.find((candidate) => candidate[RESOURCE_MATCHES]?.(params.uri));
1017
+ return resource?.[RESOURCE_NAME];
1018
+ }
1019
+ if (body.method === "prompts/get" && typeof params.name === "string") {
1020
+ return promptsByName.has(params.name) ? params.name : undefined;
1021
+ }
1022
+ if (body.method === "completion/complete" && isRecord(params.ref)) {
1023
+ const reference = params.ref;
1024
+ return reference.type === "ref/prompt" && typeof reference.name === "string"
1025
+ ? (promptsByName.has(reference.name) ? reference.name : undefined)
1026
+ : reference.type === "ref/resource" && typeof reference.uri === "string"
1027
+ ? resourceTemplates.find((candidate) => candidate[RESOURCE_URI] === reference.uri)?.[RESOURCE_NAME]
1028
+ : undefined;
1029
+ }
1030
+ return undefined;
921
1031
  }
922
1032
  function principalFrom(authInfo) {
923
1033
  if (!authInfo)
924
- throw new Error("Protected tool reached execution without verified authorization");
925
- return {
1034
+ throw new Error("Protected capability reached execution without verified authorization");
1035
+ if (typeof authInfo.clientId !== "string" || !authInfo.clientId.trim() || authInfo.clientId.length > 256) {
1036
+ throw new TypeError("Authentication verifier returned an invalid clientId");
1037
+ }
1038
+ if (!Array.isArray(authInfo.scopes) || authInfo.scopes.some((scope) => typeof scope !== "string" || !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)) || new Set(authInfo.scopes).size !== authInfo.scopes.length) {
1039
+ throw new TypeError("Authentication verifier returned invalid permissions");
1040
+ }
1041
+ const permissions = Object.freeze([...authInfo.scopes]);
1042
+ return Object.freeze({
926
1043
  clientId: authInfo.clientId,
927
- scopes: [...authInfo.scopes],
1044
+ permissions,
928
1045
  resource: authInfo.resource?.href,
929
- };
1046
+ });
1047
+ }
1048
+ function accessAllows(access, principal) {
1049
+ if (access === "public")
1050
+ return true;
1051
+ return principal !== undefined &&
1052
+ access.requiredScopes.every((permission) => principal.permissions.includes(permission));
930
1053
  }
931
1054
  export async function serveEmseepea(app, options = {}) {
932
1055
  const runtime = runtimes.get(app);
@@ -938,8 +1061,13 @@ export async function serveEmseepea(app, options = {}) {
938
1061
  }
939
1062
  const port = nonNegativePort(options.port ?? 3000);
940
1063
  const shutdownTimeoutMs = positiveInteger("shutdownTimeoutMs", options.shutdownTimeoutMs ?? 5_000);
941
- const flushTelemetry = options.flushTelemetry;
942
- const telemetryFlushTimeoutMs = callbackTimeout("flushTelemetry", flushTelemetry, "telemetryFlushTimeoutMs", options.telemetryFlushTimeoutMs);
1064
+ const observabilityFlushTimeoutMs = positiveInteger("observabilityFlushTimeoutMs", options.observabilityFlushTimeoutMs === undefined ? 1_000 : options.observabilityFlushTimeoutMs);
1065
+ if (observabilityFlushTimeoutMs > 60_000) {
1066
+ throw new TypeError("observabilityFlushTimeoutMs must not exceed 60000");
1067
+ }
1068
+ if (runtime.observabilityLimits) {
1069
+ runtime.observabilityLimits.deliveryTimeoutMs = observabilityFlushTimeoutMs;
1070
+ }
943
1071
  await app.listen({ host, port });
944
1072
  app.server.headersTimeout = 10_000;
945
1073
  app.server.keepAliveTimeout = 5_000;
@@ -957,7 +1085,7 @@ export async function serveEmseepea(app, options = {}) {
957
1085
  url: new URL(`http://${urlHost}:${address.port}/mcp`),
958
1086
  close: () => {
959
1087
  runtime.stopping.abort();
960
- return closing ??= closeApp(app, shutdownTimeoutMs, flushTelemetry, telemetryFlushTimeoutMs, runtime.finishTelemetry);
1088
+ return closing ??= closeApp(app, shutdownTimeoutMs, observabilityFlushTimeoutMs, runtime.finishObservability);
961
1089
  },
962
1090
  };
963
1091
  }
@@ -1037,26 +1165,34 @@ class FixedWindowRateLimiter {
1037
1165
  return "allowed";
1038
1166
  }
1039
1167
  }
1040
- function normalizeToolAccess(access, requiredScopes) {
1168
+ function normalizeCapabilityAccess(kind, access, requiredScopes) {
1041
1169
  if (access === "public")
1042
1170
  return access;
1043
1171
  if (access !== "protected" || !Array.isArray(requiredScopes) || requiredScopes.length === 0) {
1044
- throw new TypeError('Tool access must be explicitly declared as "public" or protected with scopes');
1172
+ throw new TypeError(`${kind} access must be explicitly declared as "public" or protected with scopes`);
1045
1173
  }
1046
1174
  const scopes = requiredScopes.map((scope) => {
1047
1175
  if (typeof scope !== "string" || !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)) {
1048
- throw new TypeError("Protected tool scopes must be valid OAuth scope tokens");
1176
+ throw new TypeError(`Protected ${kind.toLowerCase()} scopes must be valid OAuth scope tokens`);
1049
1177
  }
1050
1178
  return scope;
1051
1179
  });
1052
1180
  if (new Set(scopes).size !== scopes.length) {
1053
- throw new TypeError("Protected tool scopes must be unique");
1181
+ throw new TypeError(`Protected ${kind.toLowerCase()} scopes must be unique`);
1054
1182
  }
1055
1183
  return { type: "protected", requiredScopes: scopes };
1056
1184
  }
1057
- function normalizeOAuth(options) {
1185
+ function accessMetadata(metadata, access) {
1186
+ return Object.freeze({
1187
+ ...metadata,
1188
+ "io.emseepea/access": access === "public"
1189
+ ? Object.freeze({ type: "public" })
1190
+ : Object.freeze({ type: "protected", requiredScopes: [...access.requiredScopes] }),
1191
+ });
1192
+ }
1193
+ function normalizeAuthentication(options) {
1058
1194
  if (!options.verifier || typeof options.verifier.verifyAccessToken !== "function") {
1059
- throw new TypeError("oauth.verifier must implement verifyAccessToken");
1195
+ throw new TypeError("authentication.verifier must implement verifyAccessToken");
1060
1196
  }
1061
1197
  if (options.metadata.dangerouslyAllowInsecureIssuerUrl === true) {
1062
1198
  throw new TypeError("Insecure OAuth issuer URLs are not supported");
@@ -1072,9 +1208,37 @@ function normalizeOAuth(options) {
1072
1208
  verifier: options.verifier,
1073
1209
  metadata: options.metadata,
1074
1210
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(options.metadata.resourceServerUrl),
1075
- verificationTimeoutMs: positiveInteger("oauth.verificationTimeoutMs", options.verificationTimeoutMs ?? 10_000),
1211
+ verificationTimeoutMs: positiveInteger("authentication.verificationTimeoutMs", options.verificationTimeoutMs ?? 10_000),
1212
+ discovery: options.discovery === undefined || options.discovery === "public"
1213
+ ? "public"
1214
+ : options.discovery === "protected"
1215
+ ? "protected"
1216
+ : (() => { throw new TypeError('authentication.discovery must be "public" or "protected"'); })(),
1076
1217
  };
1077
1218
  }
1219
+ function normalizeObservability(adapters) {
1220
+ if (adapters === undefined)
1221
+ return [];
1222
+ if (!Array.isArray(adapters))
1223
+ throw new TypeError("observability must be an array");
1224
+ const ids = new Set();
1225
+ return Object.freeze(adapters.map((adapter) => {
1226
+ if (!adapter || typeof adapter !== "object" ||
1227
+ typeof adapter.id !== "string" || !/^[A-Za-z0-9_.-]{1,64}$/.test(adapter.id) ||
1228
+ typeof adapter.emit !== "function" ||
1229
+ adapter.flush !== undefined && typeof adapter.flush !== "function") {
1230
+ throw new TypeError("observability adapters require a valid id, emit function, and optional flush function");
1231
+ }
1232
+ if (ids.has(adapter.id))
1233
+ throw new TypeError(`Duplicate observability adapter id: ${adapter.id}`);
1234
+ ids.add(adapter.id);
1235
+ return Object.freeze({
1236
+ id: adapter.id,
1237
+ emit: adapter.emit,
1238
+ ...(adapter.flush ? { flush: adapter.flush } : {}),
1239
+ });
1240
+ }));
1241
+ }
1078
1242
  function isLoopbackUrl(url) {
1079
1243
  return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
1080
1244
  }
@@ -1448,6 +1612,7 @@ function completionCallback(complete, currentName, allowedNames, timeoutMs, maxA
1448
1612
  const values = await complete(value, {
1449
1613
  signal,
1450
1614
  deadlineMs,
1615
+ principal: request?.principal,
1451
1616
  arguments: completionArguments(context?.arguments, allowedArguments),
1452
1617
  });
1453
1618
  signal.throwIfAborted();
@@ -1679,7 +1844,7 @@ function assertResultSize(result, maxApplicationResultBytes, deadlineMs, signal)
1679
1844
  function isLoopbackHost(host) {
1680
1845
  return host === "127.0.0.1" || host === "::1" || host === "localhost";
1681
1846
  }
1682
- async function closeApp(app, timeoutMs, flushTelemetry, flushTimeoutMs, finishTelemetry) {
1847
+ async function closeApp(app, timeoutMs, flushTimeoutMs, finishObservability) {
1683
1848
  let timer;
1684
1849
  let onClosed;
1685
1850
  const httpClosed = new Promise((resolve) => { onClosed = resolve; });
@@ -1698,21 +1863,14 @@ async function closeApp(app, timeoutMs, flushTelemetry, flushTimeoutMs, finishTe
1698
1863
  finally {
1699
1864
  if (timer)
1700
1865
  clearTimeout(timer);
1701
- if (flushTelemetry) {
1702
- const controller = new AbortController();
1703
- const flushDeadline = Date.now() + flushTimeoutMs;
1866
+ if (finishObservability) {
1704
1867
  const expired = new Promise((resolve) => {
1705
1868
  // Keep the process alive long enough to finish flushing after sockets close.
1706
- timer = setTimeout(() => { controller.abort(); resolve(); }, flushTimeoutMs);
1869
+ timer = setTimeout(resolve, flushTimeoutMs);
1707
1870
  });
1708
1871
  try {
1709
1872
  await Promise.race([
1710
- httpClosed.then(async () => {
1711
- await finishTelemetry?.();
1712
- if (!controller.signal.aborted && Date.now() < flushDeadline) {
1713
- await flushTelemetry({ signal: controller.signal });
1714
- }
1715
- }).catch(() => { }),
1873
+ httpClosed.then(() => finishObservability(flushTimeoutMs)).catch(() => { }),
1716
1874
  expired,
1717
1875
  ]);
1718
1876
  }