@emseepea/server 0.3.2 → 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,
@@ -490,7 +496,9 @@ function createCheckedTool(definition, execute, streaming, allowsInputRequired)
490
496
  assertResultSize(result, maxApplicationResultBytes, deadlineMs, signal);
491
497
  return result;
492
498
  }
493
- if (!isRecord(result) || typeof result.text !== "string" || !("data" in result)) {
499
+ if (!isRecord(result) ||
500
+ (result.text !== undefined && typeof result.text !== "string") ||
501
+ !("data" in result)) {
494
502
  throw new Error("Tool returned an invalid result");
495
503
  }
496
504
  signal.throwIfAborted();
@@ -499,7 +507,7 @@ function createCheckedTool(definition, execute, streaming, allowsInputRequired)
499
507
  throw new Error("Tool returned output that does not match its schema");
500
508
  }
501
509
  const publicResult = {
502
- content: [{ type: "text", text: result.text }],
510
+ content: [{ type: "text", text: result.text ?? JSON.stringify(parsedOutput.data) }],
503
511
  structuredContent: parsedOutput.data,
504
512
  isError: false,
505
513
  };
@@ -564,12 +572,24 @@ function progressReporter(context, signal, maxEvents, maxEventBytes) {
564
572
  },
565
573
  async finish() {
566
574
  closed = true;
567
- await Promise.allSettled([...pending]);
575
+ await Promise.allSettled(pending);
568
576
  },
569
577
  throwIfFailed() { if (failure)
570
578
  throw failure; },
571
579
  };
572
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
+ }
573
593
  function normalizeListPagination(options) {
574
594
  const pageSize = positiveInteger("listPagination.pageSize", options.pageSize);
575
595
  if (pageSize > 100)
@@ -661,9 +681,7 @@ function installListPagination(server, pagination) {
661
681
  server.server.setRequestHandler("prompts/list", (request) => paginationPage(prompts, request.params?.cursor));
662
682
  }
663
683
  export function createEmseepea(options) {
664
- if (options.telemetry !== undefined && typeof options.telemetry !== "boolean") {
665
- throw new TypeError("telemetry must be a boolean");
666
- }
684
+ const observability = normalizeObservability(options.observability);
667
685
  const readiness = options.readiness;
668
686
  const readinessTimeoutMs = callbackTimeout("readiness", readiness, "readinessTimeoutMs", options.readinessTimeoutMs);
669
687
  const stopping = new AbortController();
@@ -712,16 +730,27 @@ export function createEmseepea(options) {
712
730
  const maxProgressEventBytes = positiveInteger("maxProgressEventBytes", options.maxProgressEventBytes ?? 8 * 1024);
713
731
  const operationTimeoutMs = positiveInteger("operationTimeoutMs", options.operationTimeoutMs ?? 30_000);
714
732
  const deployment = normalizeDeployment(options.deployment ?? { mode: "loopback" });
715
- const oauth = options.oauth ? normalizeOAuth(options.oauth) : undefined;
733
+ const authentication = options.authentication
734
+ ? normalizeAuthentication(options.authentication)
735
+ : undefined;
716
736
  const hasStreaming = tools.some((tool) => tool[TOOL_STREAMING]);
717
737
  if (deployment.mode !== "loopback" &&
718
738
  tools.some((tool) => tool[TOOL_STREAMING] && tool[TOOL_ACCESS] !== "public")) {
719
739
  throw new TypeError("Protected streaming tools currently require the loopback deployment profile");
720
740
  }
721
- if (tools.some((tool) => tool[TOOL_ACCESS] !== "public") && !oauth) {
722
- 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");
723
747
  }
724
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]));
725
754
  const enabledMethods = new Set(["server/discover"]);
726
755
  if (tools.length)
727
756
  enabledMethods.add("tools/list").add("tools/call");
@@ -739,37 +768,43 @@ export function createEmseepea(options) {
739
768
  const cacheHints = options.cacheHints === undefined
740
769
  ? undefined
741
770
  : normalizeCacheHints(options.cacheHints, enabledMethods);
742
- const pagination = paginationOptions
743
- ? compileListPagination(paginationOptions, new Map([
744
- ["tools/list", tools.map((tool) => tool[TOOL_LISTING])],
745
- ["resources/list", resources
746
- .filter((resource) => resource[RESOURCE_LISTING].method === "resources/list")
747
- .map((resource) => resource[RESOURCE_LISTING].value)],
748
- ["resources/templates/list", resources
749
- .filter((resource) => resource[RESOURCE_LISTING].method === "resources/templates/list")
750
- .map((resource) => resource[RESOURCE_LISTING].value)],
751
- ["prompts/list", prompts.map((prompt) => prompt[PROMPT_LISTING])],
752
- ]))
771
+ const basePagination = paginationOptions
772
+ ? compileListPagination(paginationOptions, catalogueListings(tools, resources, prompts))
753
773
  : undefined;
754
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]);
755
787
  const server = new McpServer(serverInfo, {
756
788
  capabilities: {
757
- ...(tools.length ? { tools: { listChanged: false } } : {}),
758
- ...(resources.length ? { resources: { subscribe: false, listChanged: false } } : {}),
759
- ...(prompts.length ? { prompts: { listChanged: false } } : {}),
760
- ...(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: {} } : {}),
761
793
  },
762
794
  instructions: options.instructions,
763
- cacheHints,
795
+ cacheHints: request?.filterCatalogues ? undefined : cacheHints,
764
796
  supportedProtocolVersions: [PROTOCOL_VERSION],
765
797
  });
766
- for (const tool of tools) {
798
+ for (const tool of activeTools) {
767
799
  tool[REGISTER](server, operationTimeoutMs, maxApplicationResultBytes, maxProgressEvents, maxProgressEventBytes);
768
800
  }
769
- for (const resource of resources)
801
+ for (const resource of activeResources)
770
802
  resource[REGISTER](server, operationTimeoutMs, maxApplicationResultBytes);
771
- for (const prompt of prompts)
803
+ for (const prompt of activePrompts)
772
804
  prompt[REGISTER](server, operationTimeoutMs, maxApplicationResultBytes);
805
+ const pagination = request?.filterCatalogues && paginationOptions
806
+ ? compileListPagination(paginationOptions, catalogueListings(activeTools, activeResources, activePrompts))
807
+ : basePagination;
773
808
  if (pagination)
774
809
  installListPagination(server, pagination);
775
810
  return server;
@@ -780,7 +815,10 @@ export function createEmseepea(options) {
780
815
  });
781
816
  const nodeHandler = toNodeHandler(sdkHandler);
782
817
  const app = createMcpFastifyApp({ host: deployment.mode === "loopback" ? "127.0.0.1" : "0.0.0.0" });
783
- 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;
784
822
  const limiter = deployment.mode === "production-behind-proxy"
785
823
  ? new FixedWindowRateLimiter(deployment.rateLimit)
786
824
  : undefined;
@@ -791,11 +829,11 @@ export function createEmseepea(options) {
791
829
  .code(available ? 200 : 503).send(available ? "ready\n" : "not ready\n");
792
830
  });
793
831
  app.addHook("preClose", async () => { stopping.abort(); });
794
- if (oauth) {
832
+ if (authentication) {
795
833
  app.all("/.well-known/*", async (request, reply) => {
796
- 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), {
797
835
  method: request.method,
798
- }), oauth.metadata);
836
+ }), authentication.metadata);
799
837
  if (!response) {
800
838
  await reply.code(404).send();
801
839
  return;
@@ -819,20 +857,32 @@ export function createEmseepea(options) {
819
857
  await sendRpcError(reply, 404, -32601, "Method not found", requestId(request.body.id));
820
858
  return;
821
859
  }
822
- const access = protectedAccessForCall(request.body, toolsByName);
823
- 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")) {
824
864
  try {
825
- const authInfo = await verifyProtectedCall(request, reply, access, oauth);
865
+ authInfo = await verifyAuthenticatedRequest(request, reply, protectsCatalogue ? [] : access.requiredScopes, authentication);
826
866
  request.raw.auth = authInfo;
827
867
  }
828
868
  catch (error) {
829
869
  await sendWebResponse(reply, bearerAuthChallengeResponse(safeOAuthError(error), {
830
- requiredScopes: [...access.requiredScopes],
831
- resourceMetadataUrl: oauth.resourceMetadataUrl,
870
+ requiredScopes: protectsCatalogue
871
+ ? []
872
+ : [...access.requiredScopes],
873
+ resourceMetadataUrl: authentication.resourceMetadataUrl,
832
874
  }));
833
875
  return;
834
876
  }
835
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
+ }
836
886
  const disconnected = new AbortController();
837
887
  const abort = () => disconnected.abort();
838
888
  request.raw.once("aborted", abort);
@@ -841,7 +891,12 @@ export function createEmseepea(options) {
841
891
  abort();
842
892
  reply.hijack();
843
893
  try {
844
- 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));
845
900
  }
846
901
  finally {
847
902
  request.raw.off("aborted", abort);
@@ -860,7 +915,13 @@ export function createEmseepea(options) {
860
915
  }
861
916
  });
862
917
  app.addHook("onClose", async () => sdkHandler.close());
863
- 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
+ });
864
925
  return app;
865
926
  }
866
927
  function safeOAuthError(error) {
@@ -875,7 +936,7 @@ function safeOAuthError(error) {
875
936
  : "Authorization request failed";
876
937
  return new OAuthError(error.code, message);
877
938
  }
878
- async function verifyProtectedCall(request, reply, access, oauth) {
939
+ async function verifyAuthenticatedRequest(request, reply, requiredScopes, authentication) {
879
940
  const disconnected = new AbortController();
880
941
  const abort = () => disconnected.abort();
881
942
  request.raw.once("aborted", abort);
@@ -883,14 +944,20 @@ async function verifyProtectedCall(request, reply, access, oauth) {
883
944
  if (requestAlreadyClosed(request, reply))
884
945
  abort();
885
946
  try {
886
- const authInfo = await runWithDeadline(disconnected.signal, Date.now() + oauth.verificationTimeoutMs, () => verifyBearerToken(singleHeader(request.raw.rawHeaders, "authorization"), {
887
- verifier: oauth.verifier,
888
- requiredScopes: [...access.requiredScopes],
889
- 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,
890
957
  }));
891
958
  if (!authInfo.resource || authInfo.resource.hash || !checkResourceAllowed({
892
959
  requestedResource: authInfo.resource,
893
- configuredResource: oauth.metadata.resourceServerUrl,
960
+ configuredResource: authentication.metadata.resourceServerUrl,
894
961
  })) {
895
962
  throw new OAuthError(OAuthErrorCode.InvalidToken, "Token is not valid for this resource");
896
963
  }
@@ -910,21 +977,79 @@ async function sendWebResponse(reply, response) {
910
977
  const body = Buffer.from(await response.arrayBuffer());
911
978
  await reply.code(response.status).send(body.length ? body : undefined);
912
979
  }
913
- function protectedAccessForCall(body, toolsByName) {
914
- if (!isRecord(body) || body.method !== "tools/call" || !isRecord(body.params) ||
915
- typeof body.params.name !== "string")
980
+ function capabilityAccessForRequest(body, toolsByName, resourcesByUri, resourceTemplates, promptsByName) {
981
+ if (!isRecord(body) || !isRecord(body.params))
916
982
  return undefined;
917
- const access = toolsByName.get(body.params.name)?.[TOOL_ACCESS];
918
- 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;
919
1031
  }
920
1032
  function principalFrom(authInfo) {
921
1033
  if (!authInfo)
922
- throw new Error("Protected tool reached execution without verified authorization");
923
- 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({
924
1043
  clientId: authInfo.clientId,
925
- scopes: [...authInfo.scopes],
1044
+ permissions,
926
1045
  resource: authInfo.resource?.href,
927
- };
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));
928
1053
  }
929
1054
  export async function serveEmseepea(app, options = {}) {
930
1055
  const runtime = runtimes.get(app);
@@ -936,8 +1061,13 @@ export async function serveEmseepea(app, options = {}) {
936
1061
  }
937
1062
  const port = nonNegativePort(options.port ?? 3000);
938
1063
  const shutdownTimeoutMs = positiveInteger("shutdownTimeoutMs", options.shutdownTimeoutMs ?? 5_000);
939
- const flushTelemetry = options.flushTelemetry;
940
- 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
+ }
941
1071
  await app.listen({ host, port });
942
1072
  app.server.headersTimeout = 10_000;
943
1073
  app.server.keepAliveTimeout = 5_000;
@@ -955,7 +1085,7 @@ export async function serveEmseepea(app, options = {}) {
955
1085
  url: new URL(`http://${urlHost}:${address.port}/mcp`),
956
1086
  close: () => {
957
1087
  runtime.stopping.abort();
958
- return closing ??= closeApp(app, shutdownTimeoutMs, flushTelemetry, telemetryFlushTimeoutMs, runtime.finishTelemetry);
1088
+ return closing ??= closeApp(app, shutdownTimeoutMs, observabilityFlushTimeoutMs, runtime.finishObservability);
959
1089
  },
960
1090
  };
961
1091
  }
@@ -1035,26 +1165,34 @@ class FixedWindowRateLimiter {
1035
1165
  return "allowed";
1036
1166
  }
1037
1167
  }
1038
- function normalizeToolAccess(access, requiredScopes) {
1168
+ function normalizeCapabilityAccess(kind, access, requiredScopes) {
1039
1169
  if (access === "public")
1040
1170
  return access;
1041
1171
  if (access !== "protected" || !Array.isArray(requiredScopes) || requiredScopes.length === 0) {
1042
- 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`);
1043
1173
  }
1044
1174
  const scopes = requiredScopes.map((scope) => {
1045
1175
  if (typeof scope !== "string" || !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)) {
1046
- 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`);
1047
1177
  }
1048
1178
  return scope;
1049
1179
  });
1050
1180
  if (new Set(scopes).size !== scopes.length) {
1051
- throw new TypeError("Protected tool scopes must be unique");
1181
+ throw new TypeError(`Protected ${kind.toLowerCase()} scopes must be unique`);
1052
1182
  }
1053
1183
  return { type: "protected", requiredScopes: scopes };
1054
1184
  }
1055
- 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) {
1056
1194
  if (!options.verifier || typeof options.verifier.verifyAccessToken !== "function") {
1057
- throw new TypeError("oauth.verifier must implement verifyAccessToken");
1195
+ throw new TypeError("authentication.verifier must implement verifyAccessToken");
1058
1196
  }
1059
1197
  if (options.metadata.dangerouslyAllowInsecureIssuerUrl === true) {
1060
1198
  throw new TypeError("Insecure OAuth issuer URLs are not supported");
@@ -1070,9 +1208,37 @@ function normalizeOAuth(options) {
1070
1208
  verifier: options.verifier,
1071
1209
  metadata: options.metadata,
1072
1210
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(options.metadata.resourceServerUrl),
1073
- 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"'); })(),
1074
1217
  };
1075
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
+ }
1076
1242
  function isLoopbackUrl(url) {
1077
1243
  return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
1078
1244
  }
@@ -1446,6 +1612,7 @@ function completionCallback(complete, currentName, allowedNames, timeoutMs, maxA
1446
1612
  const values = await complete(value, {
1447
1613
  signal,
1448
1614
  deadlineMs,
1615
+ principal: request?.principal,
1449
1616
  arguments: completionArguments(context?.arguments, allowedArguments),
1450
1617
  });
1451
1618
  signal.throwIfAborted();
@@ -1677,7 +1844,7 @@ function assertResultSize(result, maxApplicationResultBytes, deadlineMs, signal)
1677
1844
  function isLoopbackHost(host) {
1678
1845
  return host === "127.0.0.1" || host === "::1" || host === "localhost";
1679
1846
  }
1680
- async function closeApp(app, timeoutMs, flushTelemetry, flushTimeoutMs, finishTelemetry) {
1847
+ async function closeApp(app, timeoutMs, flushTimeoutMs, finishObservability) {
1681
1848
  let timer;
1682
1849
  let onClosed;
1683
1850
  const httpClosed = new Promise((resolve) => { onClosed = resolve; });
@@ -1696,21 +1863,14 @@ async function closeApp(app, timeoutMs, flushTelemetry, flushTimeoutMs, finishTe
1696
1863
  finally {
1697
1864
  if (timer)
1698
1865
  clearTimeout(timer);
1699
- if (flushTelemetry) {
1700
- const controller = new AbortController();
1701
- const flushDeadline = Date.now() + flushTimeoutMs;
1866
+ if (finishObservability) {
1702
1867
  const expired = new Promise((resolve) => {
1703
1868
  // Keep the process alive long enough to finish flushing after sockets close.
1704
- timer = setTimeout(() => { controller.abort(); resolve(); }, flushTimeoutMs);
1869
+ timer = setTimeout(resolve, flushTimeoutMs);
1705
1870
  });
1706
1871
  try {
1707
1872
  await Promise.race([
1708
- httpClosed.then(async () => {
1709
- await finishTelemetry?.();
1710
- if (!controller.signal.aborted && Date.now() < flushDeadline) {
1711
- await flushTelemetry({ signal: controller.signal });
1712
- }
1713
- }).catch(() => { }),
1873
+ httpClosed.then(() => finishObservability(flushTimeoutMs)).catch(() => { }),
1714
1874
  expired,
1715
1875
  ]);
1716
1876
  }