@emseepea/server 0.3.3 → 0.6.1

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,
@@ -24,6 +25,7 @@ export function inputResponse(responses, key) {
24
25
  }
25
26
  const PROTOCOL_VERSION = "2026-07-28";
26
27
  const REGISTER = Symbol("register");
28
+ const DISCOVERABLE = Symbol("discoverable");
27
29
  const TOOL_NAME = Symbol("toolName");
28
30
  const TOOL_ACCESS = Symbol("toolAccess");
29
31
  const TOOL_STREAMING = Symbol("toolStreaming");
@@ -34,7 +36,9 @@ const RESOURCE_KIND = Symbol("resourceKind");
34
36
  const RESOURCE_MATCHES = Symbol("resourceMatches");
35
37
  const RESOURCE_ROUTE = Symbol("resourceRoute");
36
38
  const RESOURCE_LISTING = Symbol("resourceListing");
39
+ const RESOURCE_ACCESS = Symbol("resourceAccess");
37
40
  const PROMPT_NAME = Symbol("promptName");
41
+ const PROMPT_ACCESS = Symbol("promptAccess");
38
42
  const HAS_COMPLETION = Symbol("hasCompletion");
39
43
  const PROMPT_LISTING = Symbol("promptListing");
40
44
  const CACHEABLE_METHODS = [
@@ -86,6 +90,7 @@ export function defineMappedTool(definition) {
86
90
  export function defineResource(definition) {
87
91
  const { name, handler } = definition;
88
92
  assertRegistrationName("Resource", name);
93
+ const access = normalizeCapabilityAccess("Resource", definition.access, definition.requiredScopes);
89
94
  const uri = canonicalResourceUri(definition.uri);
90
95
  const listing = checkedProtocolValue("Resource", {
91
96
  name,
@@ -96,7 +101,7 @@ export function defineResource(definition) {
96
101
  icons: definition.icons,
97
102
  annotations: definition.annotations,
98
103
  size: definition.size,
99
- _meta: definition._meta,
104
+ _meta: accessMetadata(definition._meta, access),
100
105
  });
101
106
  const metadata = Object.freeze({
102
107
  title: listing.title,
@@ -111,9 +116,11 @@ export function defineResource(definition) {
111
116
  ? undefined
112
117
  : normalizeCacheHint(definition.cacheHint, `resource ${name}`);
113
118
  const registration = {
119
+ [DISCOVERABLE]: normalizeDiscoverable("Resource", definition.discoverable),
114
120
  [RESOURCE_NAME]: name,
115
121
  [RESOURCE_URI]: uri,
116
122
  [RESOURCE_KIND]: "static",
123
+ [RESOURCE_ACCESS]: access,
117
124
  [RESOURCE_LISTING]: Object.freeze({
118
125
  method: "resources/list",
119
126
  value: listing,
@@ -128,6 +135,7 @@ export function defineResource(definition) {
128
135
  const result = await handler({
129
136
  signal,
130
137
  deadlineMs,
138
+ principal: access === "public" ? undefined : principalFrom(context.http?.authInfo),
131
139
  inputResponses: checkedInputResponses(context.mcpReq.inputResponses),
132
140
  });
133
141
  signal.throwIfAborted();
@@ -156,6 +164,7 @@ export function defineResource(definition) {
156
164
  export function defineResourceTemplate(definition) {
157
165
  const { name, handler } = definition;
158
166
  assertRegistrationName("Resource template", name);
167
+ const access = normalizeCapabilityAccess("Resource template", definition.access, definition.requiredScopes);
159
168
  const { template, route } = checkedResourceTemplate(definition.uriTemplate);
160
169
  const uriTemplate = template.uriTemplate.toString();
161
170
  const listing = checkedProtocolValue("ResourceTemplate", {
@@ -166,7 +175,7 @@ export function defineResourceTemplate(definition) {
166
175
  mimeType: definition.mimeType,
167
176
  icons: definition.icons,
168
177
  annotations: definition.annotations,
169
- _meta: definition._meta,
178
+ _meta: accessMetadata(definition._meta, access),
170
179
  });
171
180
  const metadata = Object.freeze({
172
181
  title: listing.title,
@@ -182,9 +191,11 @@ export function defineResourceTemplate(definition) {
182
191
  const variableNames = [...template.uriTemplate.variableNames];
183
192
  const completions = checkedCompletionHandlers("Resource template", definition.complete, variableNames);
184
193
  const registration = {
194
+ [DISCOVERABLE]: normalizeDiscoverable("Resource template", definition.discoverable),
185
195
  [RESOURCE_NAME]: name,
186
196
  [RESOURCE_URI]: uriTemplate,
187
197
  [RESOURCE_KIND]: "template",
198
+ [RESOURCE_ACCESS]: access,
188
199
  [RESOURCE_MATCHES]: (uri) => template.uriTemplate.match(uri) !== null,
189
200
  [RESOURCE_ROUTE]: route,
190
201
  [RESOURCE_LISTING]: Object.freeze({
@@ -210,6 +221,7 @@ export function defineResourceTemplate(definition) {
210
221
  const result = await handler({ uri: requestedUri.href, variables }, {
211
222
  signal,
212
223
  deadlineMs,
224
+ principal: access === "public" ? undefined : principalFrom(context.http?.authInfo),
213
225
  inputResponses: checkedInputResponses(context.mcpReq.inputResponses),
214
226
  });
215
227
  signal.throwIfAborted();
@@ -239,6 +251,7 @@ export function defineResourceTemplate(definition) {
239
251
  export function definePrompt(definition) {
240
252
  const { name, argsSchema, handler } = definition;
241
253
  assertRegistrationName("Prompt", name);
254
+ const access = normalizeCapabilityAccess("Prompt", definition.access, definition.requiredScopes);
242
255
  const argumentNames = Object.keys(argsSchema.shape);
243
256
  const completions = checkedCompletionHandlers("Prompt", definition.complete, argumentNames);
244
257
  const listing = checkedProtocolValue("Prompt", {
@@ -246,7 +259,7 @@ export function definePrompt(definition) {
246
259
  title: definition.title,
247
260
  description: definition.description,
248
261
  icons: definition.icons,
249
- _meta: definition._meta,
262
+ _meta: accessMetadata(definition._meta, access),
250
263
  arguments: promptArguments(argsSchema),
251
264
  });
252
265
  const metadata = Object.freeze({
@@ -256,7 +269,9 @@ export function definePrompt(definition) {
256
269
  _meta: listing._meta,
257
270
  });
258
271
  const registration = {
272
+ [DISCOVERABLE]: normalizeDiscoverable("Prompt", definition.discoverable),
259
273
  [PROMPT_NAME]: name,
274
+ [PROMPT_ACCESS]: access,
260
275
  [HAS_COMPLETION]: completions.size > 0,
261
276
  [PROMPT_LISTING]: listing,
262
277
  [REGISTER](server, timeoutMs, maxApplicationResultBytes) {
@@ -274,6 +289,7 @@ export function definePrompt(definition) {
274
289
  const result = await handler(parsedArgs.data, {
275
290
  signal,
276
291
  deadlineMs,
292
+ principal: access === "public" ? undefined : principalFrom(context.http?.authInfo),
277
293
  inputResponses: checkedInputResponses(context.mcpReq.inputResponses),
278
294
  });
279
295
  signal.throwIfAborted();
@@ -420,10 +436,7 @@ function createCheckedTool(definition, execute, streaming, allowsInputRequired)
420
436
  const { name, inputSchema, outputSchema } = definition;
421
437
  assertRegistrationName("Tool", name);
422
438
  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] });
439
+ const access = normalizeCapabilityAccess("Tool", definition.access, definition.requiredScopes);
427
440
  const sdkInputSchema = sdkMetadataSchema(inputSchema);
428
441
  const sdkOutputSchema = sdkMetadataSchema(outputSchema);
429
442
  const listing = checkedProtocolValue("Tool", {
@@ -434,10 +447,7 @@ function createCheckedTool(definition, execute, streaming, allowsInputRequired)
434
447
  annotations: definition.annotations,
435
448
  inputSchema: jsonMetadataSchema(inputSchema, "input"),
436
449
  outputSchema: jsonMetadataSchema(outputSchema, "output"),
437
- _meta: {
438
- ...definition._meta,
439
- "io.emseepea/access": publicAccess,
440
- },
450
+ _meta: accessMetadata(definition._meta, access),
441
451
  });
442
452
  const metadata = Object.freeze({
443
453
  title: listing.title,
@@ -449,6 +459,7 @@ function createCheckedTool(definition, execute, streaming, allowsInputRequired)
449
459
  _meta: listing._meta,
450
460
  });
451
461
  const registration = {
462
+ [DISCOVERABLE]: normalizeDiscoverable("Tool", definition.discoverable),
452
463
  [TOOL_NAME]: name,
453
464
  [TOOL_ACCESS]: access,
454
465
  [TOOL_STREAMING]: streaming,
@@ -566,12 +577,30 @@ function progressReporter(context, signal, maxEvents, maxEventBytes) {
566
577
  },
567
578
  async finish() {
568
579
  closed = true;
569
- await Promise.allSettled([...pending]);
580
+ await Promise.allSettled(pending);
570
581
  },
571
582
  throwIfFailed() { if (failure)
572
583
  throw failure; },
573
584
  };
574
585
  }
586
+ function catalogueListings(tools, resources, prompts, supportedTools = tools, supportedResources = resources, supportedPrompts = prompts) {
587
+ const catalogues = new Map();
588
+ if (supportedTools.length)
589
+ catalogues.set("tools/list", tools.map((tool) => tool[TOOL_LISTING]));
590
+ if (supportedResources.some((resource) => resource[RESOURCE_KIND] === "static")) {
591
+ catalogues.set("resources/list", resources
592
+ .filter((resource) => resource[RESOURCE_LISTING].method === "resources/list")
593
+ .map((resource) => resource[RESOURCE_LISTING].value));
594
+ }
595
+ if (supportedResources.some((resource) => resource[RESOURCE_KIND] === "template")) {
596
+ catalogues.set("resources/templates/list", resources
597
+ .filter((resource) => resource[RESOURCE_LISTING].method === "resources/templates/list")
598
+ .map((resource) => resource[RESOURCE_LISTING].value));
599
+ }
600
+ if (supportedPrompts.length)
601
+ catalogues.set("prompts/list", prompts.map((prompt) => prompt[PROMPT_LISTING]));
602
+ return catalogues;
603
+ }
575
604
  function normalizeListPagination(options) {
576
605
  const pageSize = positiveInteger("listPagination.pageSize", options.pageSize);
577
606
  if (pageSize > 100)
@@ -584,21 +613,12 @@ function normalizeListPagination(options) {
584
613
  function compileListPagination(options, catalogues) {
585
614
  const compiled = new Map();
586
615
  for (const [method, entries] of catalogues) {
587
- if (entries.length === 0)
588
- continue;
589
- const resultKey = method === "tools/list"
590
- ? "tools"
591
- : method === "prompts/list"
592
- ? "prompts"
593
- : method === "resources/templates/list"
594
- ? "resourceTemplates"
595
- : "resources";
596
- compiled.set(method, compileCataloguePages(method, resultKey, entries, options));
616
+ compiled.set(method, compileCataloguePages(method, listResultKey(method), entries, options));
597
617
  }
598
618
  return compiled;
599
619
  }
600
620
  function compileCataloguePages(method, resultKey, entries, options) {
601
- const groups = [];
621
+ const groups = entries.length ? [] : [[]];
602
622
  for (let index = 0; index < entries.length;) {
603
623
  const group = [];
604
624
  while (group.length < options.pageSize && index < entries.length) {
@@ -634,6 +654,21 @@ function compileCataloguePages(method, resultKey, entries, options) {
634
654
  }
635
655
  return Object.freeze({ first: pages[0], byCursor });
636
656
  }
657
+ function compileUnpaginatedCatalogue(catalogues) {
658
+ return new Map([...catalogues].map(([method, entries]) => [method, Object.freeze({
659
+ first: Object.freeze({
660
+ [listResultKey(method)]: Object.freeze([...entries]),
661
+ }),
662
+ byCursor: new Map(),
663
+ })]));
664
+ }
665
+ function listResultKey(method) {
666
+ if (method === "tools/list")
667
+ return "tools";
668
+ if (method === "prompts/list")
669
+ return "prompts";
670
+ return method === "resources/templates/list" ? "resourceTemplates" : "resources";
671
+ }
637
672
  function cataloguePageBytes(resultKey, entries, hasNext) {
638
673
  return Buffer.byteLength(JSON.stringify({
639
674
  [resultKey]: entries,
@@ -663,9 +698,7 @@ function installListPagination(server, pagination) {
663
698
  server.server.setRequestHandler("prompts/list", (request) => paginationPage(prompts, request.params?.cursor));
664
699
  }
665
700
  export function createEmseepea(options) {
666
- if (options.telemetry !== undefined && typeof options.telemetry !== "boolean") {
667
- throw new TypeError("telemetry must be a boolean");
668
- }
701
+ const observability = normalizeObservability(options.observability);
669
702
  const readiness = options.readiness;
670
703
  const readinessTimeoutMs = callbackTimeout("readiness", readiness, "readinessTimeoutMs", options.readinessTimeoutMs);
671
704
  const stopping = new AbortController();
@@ -699,9 +732,12 @@ export function createEmseepea(options) {
699
732
  icons: options.icons,
700
733
  websiteUrl: options.websiteUrl,
701
734
  });
702
- const tools = Object.freeze([...(options.tools ?? [])]);
735
+ const tools = Object.freeze([...(options.tools ?? []), ...(options.additionalTools ?? [])]);
703
736
  const resources = Object.freeze([...(options.resources ?? [])]);
704
737
  const prompts = Object.freeze([...(options.prompts ?? [])]);
738
+ const discoverableTools = Object.freeze(tools.filter((tool) => tool[DISCOVERABLE]));
739
+ const discoverableResources = Object.freeze(resources.filter((resource) => resource[DISCOVERABLE]));
740
+ const discoverablePrompts = Object.freeze(prompts.filter((prompt) => prompt[DISCOVERABLE]));
705
741
  assertUniqueToolNames(tools);
706
742
  assertUniqueResources(resources);
707
743
  assertUniquePromptNames(prompts);
@@ -714,16 +750,23 @@ export function createEmseepea(options) {
714
750
  const maxProgressEventBytes = positiveInteger("maxProgressEventBytes", options.maxProgressEventBytes ?? 8 * 1024);
715
751
  const operationTimeoutMs = positiveInteger("operationTimeoutMs", options.operationTimeoutMs ?? 30_000);
716
752
  const deployment = normalizeDeployment(options.deployment ?? { mode: "loopback" });
717
- const oauth = options.oauth ? normalizeOAuth(options.oauth) : undefined;
753
+ const authentication = options.authentication
754
+ ? normalizeAuthentication(options.authentication)
755
+ : undefined;
718
756
  const hasStreaming = tools.some((tool) => tool[TOOL_STREAMING]);
719
- if (deployment.mode !== "loopback" &&
720
- tools.some((tool) => tool[TOOL_STREAMING] && tool[TOOL_ACCESS] !== "public")) {
721
- throw new TypeError("Protected streaming tools currently require the loopback deployment profile");
722
- }
723
- if (tools.some((tool) => tool[TOOL_ACCESS] !== "public") && !oauth) {
724
- throw new TypeError("Protected tools require OAuth resource-server configuration");
757
+ if ([
758
+ ...tools.map((tool) => tool[TOOL_ACCESS]),
759
+ ...resources.map((resource) => resource[RESOURCE_ACCESS]),
760
+ ...prompts.map((prompt) => prompt[PROMPT_ACCESS]),
761
+ ].some((access) => access !== "public") && !authentication) {
762
+ throw new TypeError("Protected capabilities require authentication configuration");
725
763
  }
726
764
  const toolsByName = new Map(tools.map((tool) => [tool[TOOL_NAME], tool]));
765
+ const resourcesByUri = new Map(resources
766
+ .filter((resource) => resource[RESOURCE_KIND] === "static")
767
+ .map((resource) => [resource[RESOURCE_URI], resource]));
768
+ const resourceTemplates = resources.filter((resource) => resource[RESOURCE_KIND] === "template");
769
+ const promptsByName = new Map(prompts.map((prompt) => [prompt[PROMPT_NAME], prompt]));
727
770
  const enabledMethods = new Set(["server/discover"]);
728
771
  if (tools.length)
729
772
  enabledMethods.add("tools/list").add("tools/call");
@@ -741,37 +784,66 @@ export function createEmseepea(options) {
741
784
  const cacheHints = options.cacheHints === undefined
742
785
  ? undefined
743
786
  : 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
- ]))
755
- : undefined;
787
+ const baseCatalogues = catalogueListings(discoverableTools, discoverableResources, discoverablePrompts, tools, resources, prompts);
788
+ const hasSuppressedCapabilities = discoverableTools.length !== tools.length ||
789
+ discoverableResources.length !== resources.length || discoverablePrompts.length !== prompts.length;
790
+ const basePagination = paginationOptions
791
+ ? compileListPagination(paginationOptions, baseCatalogues)
792
+ : hasSuppressedCapabilities
793
+ ? compileUnpaginatedCatalogue(baseCatalogues)
794
+ : undefined;
756
795
  const sdkHandler = createMcpHandler(() => {
796
+ const request = requestOperations.getStore();
797
+ const activeTools = request?.filterCatalogues
798
+ ? tools.filter((tool) => accessAllows(tool[TOOL_ACCESS], request.principal))
799
+ : tools;
800
+ const activeResources = request?.filterCatalogues
801
+ ? resources.filter((resource) => accessAllows(resource[RESOURCE_ACCESS], request.principal))
802
+ : resources;
803
+ const activePrompts = request?.filterCatalogues
804
+ ? prompts.filter((prompt) => accessAllows(prompt[PROMPT_ACCESS], request.principal))
805
+ : prompts;
806
+ const activeDiscoverableTools = request?.filterCatalogues
807
+ ? discoverableTools.filter((tool) => accessAllows(tool[TOOL_ACCESS], request.principal))
808
+ : discoverableTools;
809
+ const activeDiscoverableResources = request?.filterCatalogues
810
+ ? discoverableResources.filter((resource) => accessAllows(resource[RESOURCE_ACCESS], request.principal))
811
+ : discoverableResources;
812
+ const activeDiscoverablePrompts = request?.filterCatalogues
813
+ ? discoverablePrompts.filter((prompt) => accessAllows(prompt[PROMPT_ACCESS], request.principal))
814
+ : discoverablePrompts;
815
+ const activeHasCompletion = activeResources.some((resource) => resource[HAS_COMPLETION]) ||
816
+ activePrompts.some((prompt) => prompt[HAS_COMPLETION]);
757
817
  const server = new McpServer(serverInfo, {
758
818
  capabilities: {
759
- ...(tools.length ? { tools: { listChanged: false } } : {}),
760
- ...(resources.length ? { resources: { subscribe: false, listChanged: false } } : {}),
761
- ...(prompts.length ? { prompts: { listChanged: false } } : {}),
762
- ...(hasCompletion ? { completions: {} } : {}),
819
+ ...(activeTools.length ? { tools: { listChanged: false } } : {}),
820
+ ...(activeResources.length ? { resources: { subscribe: false, listChanged: false } } : {}),
821
+ ...(activePrompts.length ? { prompts: { listChanged: false } } : {}),
822
+ ...(activeHasCompletion ? { completions: {} } : {}),
763
823
  },
764
824
  instructions: options.instructions,
765
- cacheHints,
825
+ cacheHints: request?.filterCatalogues ? undefined : cacheHints,
766
826
  supportedProtocolVersions: [PROTOCOL_VERSION],
767
827
  });
768
- for (const tool of tools) {
828
+ for (const tool of activeTools) {
769
829
  tool[REGISTER](server, operationTimeoutMs, maxApplicationResultBytes, maxProgressEvents, maxProgressEventBytes);
770
830
  }
771
- for (const resource of resources)
831
+ for (const resource of activeResources)
772
832
  resource[REGISTER](server, operationTimeoutMs, maxApplicationResultBytes);
773
- for (const prompt of prompts)
833
+ for (const prompt of activePrompts)
774
834
  prompt[REGISTER](server, operationTimeoutMs, maxApplicationResultBytes);
835
+ const filteredCatalogues = request?.filterCatalogues
836
+ ? catalogueListings(activeDiscoverableTools, activeDiscoverableResources, activeDiscoverablePrompts, activeTools, activeResources, activePrompts)
837
+ : undefined;
838
+ const pagination = filteredCatalogues
839
+ ? paginationOptions
840
+ ? compileListPagination(paginationOptions, filteredCatalogues)
841
+ : activeDiscoverableTools.length !== activeTools.length ||
842
+ activeDiscoverableResources.length !== activeResources.length ||
843
+ activeDiscoverablePrompts.length !== activePrompts.length
844
+ ? compileUnpaginatedCatalogue(filteredCatalogues)
845
+ : undefined
846
+ : basePagination;
775
847
  if (pagination)
776
848
  installListPagination(server, pagination);
777
849
  return server;
@@ -782,7 +854,10 @@ export function createEmseepea(options) {
782
854
  });
783
855
  const nodeHandler = toNodeHandler(sdkHandler);
784
856
  const app = createMcpFastifyApp({ host: deployment.mode === "loopback" ? "127.0.0.1" : "0.0.0.0" });
785
- const finishTelemetry = options.telemetry === true ? installRequestTelemetry(app) : undefined;
857
+ const observabilityLimits = observability.length ? { deliveryTimeoutMs: 1_000 } : undefined;
858
+ const finishObservability = observabilityLimits
859
+ ? installObservability(app, observability, (body) => capabilityNameForRequest(body, toolsByName, resourcesByUri, resourceTemplates, promptsByName), observabilityLimits)
860
+ : undefined;
786
861
  const limiter = deployment.mode === "production-behind-proxy"
787
862
  ? new FixedWindowRateLimiter(deployment.rateLimit)
788
863
  : undefined;
@@ -793,11 +868,11 @@ export function createEmseepea(options) {
793
868
  .code(available ? 200 : 503).send(available ? "ready\n" : "not ready\n");
794
869
  });
795
870
  app.addHook("preClose", async () => { stopping.abort(); });
796
- if (oauth) {
871
+ if (authentication) {
797
872
  app.all("/.well-known/*", async (request, reply) => {
798
- const response = oauthMetadataResponse(new Request(new URL(request.url, oauth.metadata.resourceServerUrl.origin), {
873
+ const response = oauthMetadataResponse(new Request(new URL(request.url, authentication.metadata.resourceServerUrl.origin), {
799
874
  method: request.method,
800
- }), oauth.metadata);
875
+ }), authentication.metadata);
801
876
  if (!response) {
802
877
  await reply.code(404).send();
803
878
  return;
@@ -821,20 +896,32 @@ export function createEmseepea(options) {
821
896
  await sendRpcError(reply, 404, -32601, "Method not found", requestId(request.body.id));
822
897
  return;
823
898
  }
824
- const access = protectedAccessForCall(request.body, toolsByName);
825
- if (access && oauth) {
899
+ const access = capabilityAccessForRequest(request.body, toolsByName, resourcesByUri, resourceTemplates, promptsByName);
900
+ const protectsCatalogue = authentication?.discovery === "protected";
901
+ let authInfo;
902
+ if (authentication && (protectsCatalogue || access && access !== "public")) {
826
903
  try {
827
- const authInfo = await verifyProtectedCall(request, reply, access, oauth);
904
+ authInfo = await verifyAuthenticatedRequest(request, reply, protectsCatalogue ? [] : access.requiredScopes, authentication);
828
905
  request.raw.auth = authInfo;
829
906
  }
830
907
  catch (error) {
831
908
  await sendWebResponse(reply, bearerAuthChallengeResponse(safeOAuthError(error), {
832
- requiredScopes: [...access.requiredScopes],
833
- resourceMetadataUrl: oauth.resourceMetadataUrl,
909
+ requiredScopes: protectsCatalogue
910
+ ? []
911
+ : [...access.requiredScopes],
912
+ resourceMetadataUrl: authentication.resourceMetadataUrl,
834
913
  }));
835
914
  return;
836
915
  }
837
916
  }
917
+ const principal = authInfo ? principalFrom(authInfo) : undefined;
918
+ if (protectsCatalogue)
919
+ reply.raw.setHeader("cache-control", "private, no-store");
920
+ if (protectsCatalogue && isCapabilityInvocation(request.body) &&
921
+ (access === undefined || !accessAllows(access, principal))) {
922
+ await sendRpcError(reply, 200, -32602, "Capability not found", requestId(isRecord(request.body) ? request.body.id : undefined));
923
+ return;
924
+ }
838
925
  const disconnected = new AbortController();
839
926
  const abort = () => disconnected.abort();
840
927
  request.raw.once("aborted", abort);
@@ -843,7 +930,12 @@ export function createEmseepea(options) {
843
930
  abort();
844
931
  reply.hijack();
845
932
  try {
846
- await requestOperations.run({ deadlineMs: Date.now() + operationTimeoutMs, signal: disconnected.signal }, () => nodeHandler(request.raw, reply.raw, request.body));
933
+ await requestOperations.run({
934
+ deadlineMs: Date.now() + operationTimeoutMs,
935
+ signal: disconnected.signal,
936
+ principal,
937
+ filterCatalogues: protectsCatalogue,
938
+ }, () => nodeHandler(request.raw, reply.raw, request.body));
847
939
  }
848
940
  finally {
849
941
  request.raw.off("aborted", abort);
@@ -862,7 +954,13 @@ export function createEmseepea(options) {
862
954
  }
863
955
  });
864
956
  app.addHook("onClose", async () => sdkHandler.close());
865
- runtimes.set(app, { deployment, requestTimeoutMs: operationTimeoutMs + 5_000, stopping, finishTelemetry });
957
+ runtimes.set(app, {
958
+ deployment,
959
+ requestTimeoutMs: operationTimeoutMs + 5_000,
960
+ stopping,
961
+ observabilityLimits,
962
+ finishObservability,
963
+ });
866
964
  return app;
867
965
  }
868
966
  function safeOAuthError(error) {
@@ -877,7 +975,7 @@ function safeOAuthError(error) {
877
975
  : "Authorization request failed";
878
976
  return new OAuthError(error.code, message);
879
977
  }
880
- async function verifyProtectedCall(request, reply, access, oauth) {
978
+ async function verifyAuthenticatedRequest(request, reply, requiredScopes, authentication) {
881
979
  const disconnected = new AbortController();
882
980
  const abort = () => disconnected.abort();
883
981
  request.raw.once("aborted", abort);
@@ -885,14 +983,20 @@ async function verifyProtectedCall(request, reply, access, oauth) {
885
983
  if (requestAlreadyClosed(request, reply))
886
984
  abort();
887
985
  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,
986
+ const authInfo = await runWithDeadline(disconnected.signal, Date.now() + authentication.verificationTimeoutMs, () => verifyBearerToken(singleHeader(request.raw.rawHeaders, "authorization"), {
987
+ verifier: {
988
+ async verifyAccessToken(token) {
989
+ const authInfo = await authentication.verifier.verifyAccessToken(token);
990
+ principalFrom(authInfo);
991
+ return authInfo;
992
+ },
993
+ },
994
+ requiredScopes: [...requiredScopes],
995
+ resourceMetadataUrl: authentication.resourceMetadataUrl,
892
996
  }));
893
997
  if (!authInfo.resource || authInfo.resource.hash || !checkResourceAllowed({
894
998
  requestedResource: authInfo.resource,
895
- configuredResource: oauth.metadata.resourceServerUrl,
999
+ configuredResource: authentication.metadata.resourceServerUrl,
896
1000
  })) {
897
1001
  throw new OAuthError(OAuthErrorCode.InvalidToken, "Token is not valid for this resource");
898
1002
  }
@@ -912,21 +1016,79 @@ async function sendWebResponse(reply, response) {
912
1016
  const body = Buffer.from(await response.arrayBuffer());
913
1017
  await reply.code(response.status).send(body.length ? body : undefined);
914
1018
  }
915
- function protectedAccessForCall(body, toolsByName) {
916
- if (!isRecord(body) || body.method !== "tools/call" || !isRecord(body.params) ||
917
- typeof body.params.name !== "string")
1019
+ function capabilityAccessForRequest(body, toolsByName, resourcesByUri, resourceTemplates, promptsByName) {
1020
+ if (!isRecord(body) || !isRecord(body.params))
1021
+ return undefined;
1022
+ const params = body.params;
1023
+ if (body.method === "tools/call" && typeof params.name === "string") {
1024
+ return toolsByName.get(params.name)?.[TOOL_ACCESS];
1025
+ }
1026
+ if (body.method === "resources/read" && typeof params.uri === "string") {
1027
+ return resourcesByUri.get(params.uri)?.[RESOURCE_ACCESS] ?? resourceTemplates.find((resource) => resource[RESOURCE_MATCHES]?.(params.uri))?.[RESOURCE_ACCESS];
1028
+ }
1029
+ if (body.method === "prompts/get" && typeof params.name === "string") {
1030
+ return promptsByName.get(params.name)?.[PROMPT_ACCESS];
1031
+ }
1032
+ if (body.method === "completion/complete" && isRecord(params.ref)) {
1033
+ const reference = params.ref;
1034
+ if (reference.type === "ref/prompt" && typeof reference.name === "string") {
1035
+ return promptsByName.get(reference.name)?.[PROMPT_ACCESS];
1036
+ }
1037
+ if (reference.type === "ref/resource" && typeof reference.uri === "string") {
1038
+ return resourceTemplates.find((resource) => resource[RESOURCE_URI] === reference.uri)?.[RESOURCE_ACCESS];
1039
+ }
1040
+ }
1041
+ return undefined;
1042
+ }
1043
+ function isCapabilityInvocation(body) {
1044
+ return isRecord(body) && (body.method === "tools/call" || body.method === "resources/read" ||
1045
+ body.method === "prompts/get" || body.method === "completion/complete");
1046
+ }
1047
+ function capabilityNameForRequest(body, toolsByName, resourcesByUri, resourceTemplates, promptsByName) {
1048
+ if (!isRecord(body) || !isRecord(body.params))
918
1049
  return undefined;
919
- const access = toolsByName.get(body.params.name)?.[TOOL_ACCESS];
920
- return access === "public" ? undefined : access;
1050
+ const params = body.params;
1051
+ if (body.method === "tools/call" && typeof params.name === "string") {
1052
+ return toolsByName.has(params.name) ? params.name : undefined;
1053
+ }
1054
+ if (body.method === "resources/read" && typeof params.uri === "string") {
1055
+ const resource = resourcesByUri.get(params.uri) ?? resourceTemplates.find((candidate) => candidate[RESOURCE_MATCHES]?.(params.uri));
1056
+ return resource?.[RESOURCE_NAME];
1057
+ }
1058
+ if (body.method === "prompts/get" && typeof params.name === "string") {
1059
+ return promptsByName.has(params.name) ? params.name : undefined;
1060
+ }
1061
+ if (body.method === "completion/complete" && isRecord(params.ref)) {
1062
+ const reference = params.ref;
1063
+ return reference.type === "ref/prompt" && typeof reference.name === "string"
1064
+ ? (promptsByName.has(reference.name) ? reference.name : undefined)
1065
+ : reference.type === "ref/resource" && typeof reference.uri === "string"
1066
+ ? resourceTemplates.find((candidate) => candidate[RESOURCE_URI] === reference.uri)?.[RESOURCE_NAME]
1067
+ : undefined;
1068
+ }
1069
+ return undefined;
921
1070
  }
922
1071
  function principalFrom(authInfo) {
923
1072
  if (!authInfo)
924
- throw new Error("Protected tool reached execution without verified authorization");
925
- return {
1073
+ throw new Error("Protected capability reached execution without verified authorization");
1074
+ if (typeof authInfo.clientId !== "string" || !authInfo.clientId.trim() || authInfo.clientId.length > 256) {
1075
+ throw new TypeError("Authentication verifier returned an invalid clientId");
1076
+ }
1077
+ 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) {
1078
+ throw new TypeError("Authentication verifier returned invalid permissions");
1079
+ }
1080
+ const permissions = Object.freeze([...authInfo.scopes]);
1081
+ return Object.freeze({
926
1082
  clientId: authInfo.clientId,
927
- scopes: [...authInfo.scopes],
1083
+ permissions,
928
1084
  resource: authInfo.resource?.href,
929
- };
1085
+ });
1086
+ }
1087
+ function accessAllows(access, principal) {
1088
+ if (access === "public")
1089
+ return true;
1090
+ return principal !== undefined &&
1091
+ access.requiredScopes.every((permission) => principal.permissions.includes(permission));
930
1092
  }
931
1093
  export async function serveEmseepea(app, options = {}) {
932
1094
  const runtime = runtimes.get(app);
@@ -938,8 +1100,13 @@ export async function serveEmseepea(app, options = {}) {
938
1100
  }
939
1101
  const port = nonNegativePort(options.port ?? 3000);
940
1102
  const shutdownTimeoutMs = positiveInteger("shutdownTimeoutMs", options.shutdownTimeoutMs ?? 5_000);
941
- const flushTelemetry = options.flushTelemetry;
942
- const telemetryFlushTimeoutMs = callbackTimeout("flushTelemetry", flushTelemetry, "telemetryFlushTimeoutMs", options.telemetryFlushTimeoutMs);
1103
+ const observabilityFlushTimeoutMs = positiveInteger("observabilityFlushTimeoutMs", options.observabilityFlushTimeoutMs === undefined ? 1_000 : options.observabilityFlushTimeoutMs);
1104
+ if (observabilityFlushTimeoutMs > 60_000) {
1105
+ throw new TypeError("observabilityFlushTimeoutMs must not exceed 60000");
1106
+ }
1107
+ if (runtime.observabilityLimits) {
1108
+ runtime.observabilityLimits.deliveryTimeoutMs = observabilityFlushTimeoutMs;
1109
+ }
943
1110
  await app.listen({ host, port });
944
1111
  app.server.headersTimeout = 10_000;
945
1112
  app.server.keepAliveTimeout = 5_000;
@@ -957,7 +1124,7 @@ export async function serveEmseepea(app, options = {}) {
957
1124
  url: new URL(`http://${urlHost}:${address.port}/mcp`),
958
1125
  close: () => {
959
1126
  runtime.stopping.abort();
960
- return closing ??= closeApp(app, shutdownTimeoutMs, flushTelemetry, telemetryFlushTimeoutMs, runtime.finishTelemetry);
1127
+ return closing ??= closeApp(app, shutdownTimeoutMs, observabilityFlushTimeoutMs, runtime.finishObservability);
961
1128
  },
962
1129
  };
963
1130
  }
@@ -1037,26 +1204,41 @@ class FixedWindowRateLimiter {
1037
1204
  return "allowed";
1038
1205
  }
1039
1206
  }
1040
- function normalizeToolAccess(access, requiredScopes) {
1207
+ function normalizeCapabilityAccess(kind, access, requiredScopes) {
1041
1208
  if (access === "public")
1042
1209
  return access;
1043
1210
  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');
1211
+ throw new TypeError(`${kind} access must be explicitly declared as "public" or protected with scopes`);
1045
1212
  }
1046
1213
  const scopes = requiredScopes.map((scope) => {
1047
1214
  if (typeof scope !== "string" || !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)) {
1048
- throw new TypeError("Protected tool scopes must be valid OAuth scope tokens");
1215
+ throw new TypeError(`Protected ${kind.toLowerCase()} scopes must be valid OAuth scope tokens`);
1049
1216
  }
1050
1217
  return scope;
1051
1218
  });
1052
1219
  if (new Set(scopes).size !== scopes.length) {
1053
- throw new TypeError("Protected tool scopes must be unique");
1220
+ throw new TypeError(`Protected ${kind.toLowerCase()} scopes must be unique`);
1054
1221
  }
1055
1222
  return { type: "protected", requiredScopes: scopes };
1056
1223
  }
1057
- function normalizeOAuth(options) {
1224
+ function normalizeDiscoverable(kind, value) {
1225
+ if (value === undefined || value === true)
1226
+ return true;
1227
+ if (value === false)
1228
+ return false;
1229
+ throw new TypeError(`${kind} discoverable must be a boolean`);
1230
+ }
1231
+ function accessMetadata(metadata, access) {
1232
+ return Object.freeze({
1233
+ ...metadata,
1234
+ "io.emseepea/access": access === "public"
1235
+ ? Object.freeze({ type: "public" })
1236
+ : Object.freeze({ type: "protected", requiredScopes: [...access.requiredScopes] }),
1237
+ });
1238
+ }
1239
+ function normalizeAuthentication(options) {
1058
1240
  if (!options.verifier || typeof options.verifier.verifyAccessToken !== "function") {
1059
- throw new TypeError("oauth.verifier must implement verifyAccessToken");
1241
+ throw new TypeError("authentication.verifier must implement verifyAccessToken");
1060
1242
  }
1061
1243
  if (options.metadata.dangerouslyAllowInsecureIssuerUrl === true) {
1062
1244
  throw new TypeError("Insecure OAuth issuer URLs are not supported");
@@ -1072,9 +1254,37 @@ function normalizeOAuth(options) {
1072
1254
  verifier: options.verifier,
1073
1255
  metadata: options.metadata,
1074
1256
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(options.metadata.resourceServerUrl),
1075
- verificationTimeoutMs: positiveInteger("oauth.verificationTimeoutMs", options.verificationTimeoutMs ?? 10_000),
1257
+ verificationTimeoutMs: positiveInteger("authentication.verificationTimeoutMs", options.verificationTimeoutMs ?? 10_000),
1258
+ discovery: options.discovery === undefined || options.discovery === "public"
1259
+ ? "public"
1260
+ : options.discovery === "protected"
1261
+ ? "protected"
1262
+ : (() => { throw new TypeError('authentication.discovery must be "public" or "protected"'); })(),
1076
1263
  };
1077
1264
  }
1265
+ function normalizeObservability(adapters) {
1266
+ if (adapters === undefined)
1267
+ return [];
1268
+ if (!Array.isArray(adapters))
1269
+ throw new TypeError("observability must be an array");
1270
+ const ids = new Set();
1271
+ return Object.freeze(adapters.map((adapter) => {
1272
+ if (!adapter || typeof adapter !== "object" ||
1273
+ typeof adapter.id !== "string" || !/^[A-Za-z0-9_.-]{1,64}$/.test(adapter.id) ||
1274
+ typeof adapter.emit !== "function" ||
1275
+ adapter.flush !== undefined && typeof adapter.flush !== "function") {
1276
+ throw new TypeError("observability adapters require a valid id, emit function, and optional flush function");
1277
+ }
1278
+ if (ids.has(adapter.id))
1279
+ throw new TypeError(`Duplicate observability adapter id: ${adapter.id}`);
1280
+ ids.add(adapter.id);
1281
+ return Object.freeze({
1282
+ id: adapter.id,
1283
+ emit: adapter.emit,
1284
+ ...(adapter.flush ? { flush: adapter.flush } : {}),
1285
+ });
1286
+ }));
1287
+ }
1078
1288
  function isLoopbackUrl(url) {
1079
1289
  return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
1080
1290
  }
@@ -1448,6 +1658,7 @@ function completionCallback(complete, currentName, allowedNames, timeoutMs, maxA
1448
1658
  const values = await complete(value, {
1449
1659
  signal,
1450
1660
  deadlineMs,
1661
+ principal: request?.principal,
1451
1662
  arguments: completionArguments(context?.arguments, allowedArguments),
1452
1663
  });
1453
1664
  signal.throwIfAborted();
@@ -1679,7 +1890,7 @@ function assertResultSize(result, maxApplicationResultBytes, deadlineMs, signal)
1679
1890
  function isLoopbackHost(host) {
1680
1891
  return host === "127.0.0.1" || host === "::1" || host === "localhost";
1681
1892
  }
1682
- async function closeApp(app, timeoutMs, flushTelemetry, flushTimeoutMs, finishTelemetry) {
1893
+ async function closeApp(app, timeoutMs, flushTimeoutMs, finishObservability) {
1683
1894
  let timer;
1684
1895
  let onClosed;
1685
1896
  const httpClosed = new Promise((resolve) => { onClosed = resolve; });
@@ -1698,21 +1909,14 @@ async function closeApp(app, timeoutMs, flushTelemetry, flushTimeoutMs, finishTe
1698
1909
  finally {
1699
1910
  if (timer)
1700
1911
  clearTimeout(timer);
1701
- if (flushTelemetry) {
1702
- const controller = new AbortController();
1703
- const flushDeadline = Date.now() + flushTimeoutMs;
1912
+ if (finishObservability) {
1704
1913
  const expired = new Promise((resolve) => {
1705
1914
  // Keep the process alive long enough to finish flushing after sockets close.
1706
- timer = setTimeout(() => { controller.abort(); resolve(); }, flushTimeoutMs);
1915
+ timer = setTimeout(resolve, flushTimeoutMs);
1707
1916
  });
1708
1917
  try {
1709
1918
  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(() => { }),
1919
+ httpClosed.then(() => finishObservability(flushTimeoutMs)).catch(() => { }),
1716
1920
  expired,
1717
1921
  ]);
1718
1922
  }