@ttoss/http-server-mcp 0.33.2 → 0.34.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.mjs CHANGED
@@ -37,7 +37,7 @@ var getApiHeaders = () => {
37
37
  /**
38
38
  * The `Accept-Language` header of the current MCP request, or `undefined`
39
39
  * outside a request or when the client sent none. The default locale source
40
- * for `createGatedToolRegistrar`'s `i18n` option.
40
+ * for `createToolGate`'s `i18n` option.
41
41
  */
42
42
  var getRequestLocale = () => {
43
43
  return requestContextStore.getStore()?.acceptLanguage;
@@ -364,24 +364,33 @@ var registerAppResource = ({
364
364
  };
365
365
 
366
366
  //#endregion
367
- //#region src/registerToolFromSchema.ts
367
+ //#region src/tool.ts
368
+ var EMPTY_OBJECT_SCHEMA = {
369
+ type: "object",
370
+ properties: {}
371
+ };
372
+ /** The JSON Schema dialect the MCP SDK converts Standard Schemas to. */
373
+ var JSON_SCHEMA_TARGET = "draft-2020-12";
374
+ var isStandardSchema = schema => {
375
+ return "~standard" in schema;
376
+ };
368
377
  /**
369
- * Converts a plain JSON Schema into the Standard Schema `registerTool` accepts.
378
+ * Converts a JSON Schema into the Standard Schema `registerTool` accepts.
370
379
  *
371
380
  * A Standard Schema keeps advertisement and enforcement in separate fields:
372
381
  * `~standard.jsonSchema` is what `tools/list` publishes, `~standard.validate`
373
382
  * is what `tools/call` runs. Replacing only `validate` therefore keeps the
374
383
  * schema fully visible to clients while leaving arguments unchecked.
375
384
  */
376
- var toStandardSchema = ({
377
- inputSchema,
378
- validateArguments
385
+ var fromJsonObjectSchema = ({
386
+ schema,
387
+ validate
379
388
  }) => {
380
- const schema = fromJsonSchema(inputSchema);
381
- if (validateArguments) return schema;
389
+ const standard = fromJsonSchema(schema);
390
+ if (validate) return standard;
382
391
  return {
383
392
  "~standard": {
384
- ...schema["~standard"],
393
+ ...standard["~standard"],
385
394
  validate: value => {
386
395
  return {
387
396
  value
@@ -390,71 +399,53 @@ var toStandardSchema = ({
390
399
  }
391
400
  };
392
401
  };
402
+ var resolved = /* @__PURE__ */new WeakMap();
393
403
  /**
394
- * Registers a tool on an MCP server using a **plain JSON Schema** object for
395
- * `inputSchema` instead of a Zod shape.
396
- *
397
- * This is useful when tool definitions are shared between the MCP server and
398
- * an AI SDK agent (e.g. Vercel AI SDK's `tool()` helper), because both consume
399
- * a plain JSON Schema at runtime. Using this helper eliminates the lossy
400
- * JSON-Schema→Zod conversion that would otherwise be required.
401
- *
402
- * Thin wrapper over `@modelcontextprotocol/server`'s `fromJsonSchema`, which
403
- * converts a JSON Schema into a Standard Schema that `registerTool` accepts
404
- * directly, so the schema round-trips verbatim over `tools/list`. Arguments
405
- * reach the handler unchecked unless `validateArguments` is enabled.
406
- *
407
- * @param server - The `McpServer` instance to register the tool on.
408
- * @param params - Tool configuration including name, description, inputSchema,
409
- * validateArguments, `_meta`, and handler.
410
- *
411
- * @example
412
- * ```typescript
413
- * import { registerToolFromSchema, McpServer } from '@ttoss/http-server-mcp';
414
- *
415
- * const server = new McpServer({ name: 'my-server', version: '1.0.0' });
416
- *
417
- * registerToolFromSchema(server, {
418
- * name: 'get-project',
419
- * description: 'Get a project by ID',
420
- * inputSchema: {
421
- * type: 'object',
422
- * properties: { id: { type: 'string', description: 'Project public ID' } },
423
- * required: ['id'],
424
- * },
425
- * handler: async ({ id }) => ({
426
- * content: [{ type: 'text', text: `Project: ${id}` }],
427
- * }),
428
- * });
429
- * ```
404
+ * Resolves a tool's schemas once, so the direct registration and a deferred
405
+ * `call` enforce exactly the same thing.
430
406
  */
431
- var registerToolFromSchema = (server, params) => {
432
- const {
433
- name,
434
- description,
435
- inputSchema = {
436
- type: "object",
437
- properties: {}
438
- },
439
- validateArguments = false,
440
- handler,
441
- _meta
442
- } = params;
443
- server.registerTool(name, {
444
- description,
445
- inputSchema: toStandardSchema({
446
- inputSchema,
447
- validateArguments
407
+ var resolveToolSchemas = tool => {
408
+ const cached = resolved.get(tool);
409
+ if (cached) return cached;
410
+ const inputSchema = tool.inputSchema ?? EMPTY_OBJECT_SCHEMA;
411
+ const result = {
412
+ input: isStandardSchema(inputSchema) ? inputSchema : fromJsonObjectSchema({
413
+ schema: inputSchema,
414
+ validate: tool.validateArguments ?? false
448
415
  }),
449
- _meta
416
+ inputJson: isStandardSchema(inputSchema) ? inputSchema["~standard"].jsonSchema.input({
417
+ target: JSON_SCHEMA_TARGET
418
+ }) : inputSchema,
419
+ output: tool.outputSchema === void 0 || isStandardSchema(tool.outputSchema) ? tool.outputSchema : fromJsonObjectSchema({
420
+ schema: tool.outputSchema,
421
+ validate: true
422
+ })
423
+ };
424
+ resolved.set(tool, result);
425
+ return result;
426
+ };
427
+ /** Registers one tool as one MCP tool. */
428
+ var registerDirectTool = ({
429
+ server,
430
+ tool
431
+ }) => {
432
+ const schemas = resolveToolSchemas(tool);
433
+ server.registerTool(tool.name, {
434
+ title: tool.title,
435
+ description: tool.description,
436
+ inputSchema: schemas.input,
437
+ outputSchema: schemas.output,
438
+ annotations: tool.annotations,
439
+ _meta: tool._meta
450
440
  }, async args => {
451
- return handler(args);
441
+ return tool.handler(args);
452
442
  });
453
443
  };
454
-
455
- //#endregion
456
- //#region src/createGatedToolRegistrar.ts
457
- var toolError = (message, code) => {
444
+ /** An `isError` result whose text is `{ error, code? }` as JSON. */
445
+ var toolError = ({
446
+ message,
447
+ code
448
+ }) => {
458
449
  return {
459
450
  content: [{
460
451
  type: "text",
@@ -469,6 +460,552 @@ var toolError = (message, code) => {
469
460
  };
470
461
  };
471
462
  /**
463
+ * Builds a {@link Tool} from a `method` that returns data instead of a
464
+ * `CallToolResult`, which is what most hand-written tools want.
465
+ *
466
+ * @example
467
+ * ```typescript
468
+ * const getCampaign = defineTool({
469
+ * name: 'get-campaign',
470
+ * description: 'Get a campaign by id.',
471
+ * inputSchema: z.object({ campaignId: z.string() }),
472
+ * method: ({ campaignId }) => findCampaign(campaignId as string),
473
+ * notFoundMessage: 'Campaign not found',
474
+ * });
475
+ * ```
476
+ */
477
+ var defineTool = ({
478
+ method,
479
+ notFoundMessage = "Not found",
480
+ ...tool
481
+ }) => {
482
+ return {
483
+ ...tool,
484
+ handler: async args => {
485
+ const result = await method(args);
486
+ if (result == null) return toolError({
487
+ message: notFoundMessage
488
+ });
489
+ const content = [{
490
+ type: "text",
491
+ text: JSON.stringify(result)
492
+ }];
493
+ return tool.outputSchema === void 0 ? {
494
+ content
495
+ } : {
496
+ content,
497
+ structuredContent: result
498
+ };
499
+ }
500
+ };
501
+ };
502
+
503
+ //#endregion
504
+ //#region src/toolSearch.ts
505
+ var SUMMARY_MAX_LENGTH = 160;
506
+ var SUGGESTION_COUNT = 3;
507
+ /** Plural and singular forms rank alike: `agents` finds `get-agent`. */
508
+ var stem = term => {
509
+ if (term.length > 4 && term.endsWith("ies")) return `${term.slice(0, -3)}y`;
510
+ if (term.length > 3 && term.endsWith("s") && !term.endsWith("ss")) return term.slice(0, -1);
511
+ return term;
512
+ };
513
+ /** Splits kebab-case, snake_case, camelCase and prose into lowercase terms. */
514
+ var termsOf = text => {
515
+ return text.replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, "$1 $2").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(term => {
516
+ return term.length > 0;
517
+ }).map(stem);
518
+ };
519
+ /** The fields a query term is matched against, most telling first. */
520
+ var FIELDS = [{
521
+ weight: 4,
522
+ text: tool => {
523
+ return tool.name;
524
+ }
525
+ }, {
526
+ weight: 3,
527
+ text: tool => {
528
+ return tool.summary ?? "";
529
+ }
530
+ }, {
531
+ weight: 2,
532
+ text: tool => {
533
+ return (tool.tags ?? []).join(" ");
534
+ }
535
+ }, {
536
+ weight: 1,
537
+ text: tool => {
538
+ return tool.description;
539
+ }
540
+ }];
541
+ var fieldTerms = /* @__PURE__ */new WeakMap();
542
+ var termsByField = tool => {
543
+ const cached = fieldTerms.get(tool);
544
+ if (cached) return cached;
545
+ const terms = FIELDS.map(field => {
546
+ return new Set(termsOf(field.text(tool)));
547
+ });
548
+ fieldTerms.set(tool, terms);
549
+ return terms;
550
+ };
551
+ /**
552
+ * A term scores the weight of the best field holding it, half that when it
553
+ * is only a prefix of a word there (`proj` → `project`).
554
+ */
555
+ var scoreOf = ({
556
+ tool,
557
+ terms
558
+ }) => {
559
+ const fields = termsByField(tool);
560
+ let score = 0;
561
+ for (const term of terms) {
562
+ let best = 0;
563
+ for (const [index, words] of fields.entries()) {
564
+ const weight = FIELDS[index].weight;
565
+ if (words.has(term)) best = Math.max(best, weight);else if (term.length >= 3 && [...words].some(word => {
566
+ return word.startsWith(term);
567
+ })) best = Math.max(best, weight / 2);
568
+ }
569
+ score += best;
570
+ }
571
+ return score;
572
+ };
573
+ /**
574
+ * The deferred tools' default search: scores each tool by the query's terms,
575
+ * weighted name > summary > tags > description, and breaks ties by the
576
+ * tools' order. No index — a few hundred tools need none.
577
+ */
578
+ var rankTools = ({
579
+ query,
580
+ tag,
581
+ limit,
582
+ tools
583
+ }) => {
584
+ const candidates = tag === void 0 ? tools : tools.filter(tool => {
585
+ return tool.tags?.includes(tag) ?? false;
586
+ });
587
+ const terms = [...new Set(termsOf(query))];
588
+ if (terms.length === 0) return candidates.slice(0, limit);
589
+ return candidates.map((tool, index) => {
590
+ return {
591
+ tool,
592
+ index,
593
+ score: scoreOf({
594
+ tool,
595
+ terms
596
+ })
597
+ };
598
+ }).filter(({
599
+ score
600
+ }) => {
601
+ return score > 0;
602
+ }).sort((a, b) => {
603
+ return b.score - a.score || a.index - b.index;
604
+ }).slice(0, limit).map(({
605
+ tool
606
+ }) => {
607
+ return tool;
608
+ });
609
+ };
610
+ var summaryOf = tool => {
611
+ const [line] = (tool.summary ?? tool.description).trim().split("\n", 1);
612
+ return line.length > SUMMARY_MAX_LENGTH ? `${line.slice(0, SUMMARY_MAX_LENGTH - 1).trimEnd()}…` : line;
613
+ };
614
+ var editDistance = (a, b) => {
615
+ const row = Array.from({
616
+ length: b.length + 1
617
+ }, (_, index) => {
618
+ return index;
619
+ });
620
+ for (let i = 1; i <= a.length; i++) {
621
+ let diagonal = row[0];
622
+ row[0] = i;
623
+ for (let j = 1; j <= b.length; j++) {
624
+ const above = row[j];
625
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
626
+ diagonal = above;
627
+ }
628
+ }
629
+ return row[b.length];
630
+ };
631
+ /** Names a model probably meant: near-misses first, then related tools. */
632
+ var suggestionsFor = ({
633
+ name,
634
+ tools
635
+ }) => {
636
+ const tolerance = Math.max(2, Math.floor(name.length / 3));
637
+ const nearMisses = tools.map(tool => {
638
+ return {
639
+ name: tool.name,
640
+ distance: editDistance(name, tool.name)
641
+ };
642
+ }).filter(({
643
+ distance
644
+ }) => {
645
+ return distance <= tolerance;
646
+ }).sort((a, b) => {
647
+ return a.distance - b.distance;
648
+ }).map(candidate => {
649
+ return candidate.name;
650
+ });
651
+ const related = rankTools({
652
+ query: name,
653
+ limit: SUGGESTION_COUNT,
654
+ tools
655
+ }).map(tool => {
656
+ return tool.name;
657
+ });
658
+ return [...new Set([...nearMisses, ...related])].slice(0, SUGGESTION_COUNT);
659
+ };
660
+
661
+ //#endregion
662
+ //#region src/deferTools.ts
663
+ var DEFAULT_SEARCH_LIMIT = 10;
664
+ var jsonResult = ({
665
+ data,
666
+ isError = false
667
+ }) => {
668
+ return {
669
+ content: [{
670
+ type: "text",
671
+ text: JSON.stringify(data)
672
+ }],
673
+ structuredContent: data,
674
+ ...(isError ? {
675
+ isError: true
676
+ } : {})
677
+ };
678
+ };
679
+ var errorMessageOf = error => {
680
+ return error instanceof Error ? error.message : String(error);
681
+ };
682
+ var formatIssue = issue => {
683
+ const path = (issue.path ?? []).map(segment => {
684
+ return String(typeof segment === "object" ? segment.key : segment);
685
+ }).join(".");
686
+ return path ? `${path}: ${issue.message}` : issue.message;
687
+ };
688
+ var linksAppView = ({
689
+ tool
690
+ }) => {
691
+ const ui = tool._meta?.ui;
692
+ return typeof ui === "object" && ui !== null && "resourceUri" in ui || tool._meta?.["ui/resourceUri"] !== void 0;
693
+ };
694
+ var deferredToolsOf = ({
695
+ tools,
696
+ options
697
+ }) => {
698
+ const byName = /* @__PURE__ */new Map();
699
+ for (const tool of tools) {
700
+ if (byName.has(tool.name)) throw new Error(`Two tools are named "${tool.name}".`);
701
+ byName.set(tool.name, tool);
702
+ }
703
+ const isVisible = tool => {
704
+ return options.visible ? options.visible({
705
+ tool
706
+ }) : true;
707
+ };
708
+ return {
709
+ names: {
710
+ search: "search",
711
+ describe: "describe",
712
+ call: "call",
713
+ ...options.names
714
+ },
715
+ tools,
716
+ searchLimit: options.searchLimit ?? DEFAULT_SEARCH_LIMIT,
717
+ searchTools: options.search ?? rankTools,
718
+ visibleTools: () => {
719
+ return tools.filter(isVisible);
720
+ },
721
+ find: name => {
722
+ const tool = byName.get(name);
723
+ return tool && isVisible(tool) ? tool : void 0;
724
+ }
725
+ };
726
+ };
727
+ var searchToolOf = ({
728
+ names,
729
+ tools,
730
+ searchLimit,
731
+ searchTools,
732
+ visibleTools
733
+ }) => {
734
+ const tags = [...new Set(tools.flatMap(tool => {
735
+ return tool.tags ?? [];
736
+ }))].sort();
737
+ return {
738
+ name: names.search,
739
+ description: `Find tools by keyword among the ${tools.length} this server offers. Returns each match's name and summary; get input schemas with "${names.describe}", then run one with "${names.call}".` + (tags.length > 0 ? ` Tags: ${tags.join(", ")}.` : ""),
740
+ inputSchema: {
741
+ type: "object",
742
+ properties: {
743
+ query: {
744
+ type: "string",
745
+ description: "Words describing the action, e.g. \"create agent\". Empty lists every tool."
746
+ },
747
+ tag: {
748
+ type: "string",
749
+ description: "Only tools with this tag.",
750
+ ...(tags.length > 0 ? {
751
+ enum: tags
752
+ } : {})
753
+ },
754
+ limit: {
755
+ type: "integer",
756
+ minimum: 1,
757
+ description: `Maximum number of results. Defaults to ${searchLimit}.`
758
+ }
759
+ },
760
+ required: ["query"]
761
+ },
762
+ validateArguments: true,
763
+ annotations: {
764
+ readOnlyHint: true
765
+ },
766
+ handler: async args => {
767
+ const limit = args.limit ?? searchLimit;
768
+ return jsonResult({
769
+ data: {
770
+ tools: (await searchTools({
771
+ query: args.query,
772
+ tag: args.tag,
773
+ limit,
774
+ tools: visibleTools()
775
+ })).slice(0, limit).map(tool => {
776
+ return {
777
+ name: tool.name,
778
+ summary: summaryOf(tool),
779
+ ...(tool.tags ? {
780
+ tags: tool.tags
781
+ } : {})
782
+ };
783
+ })
784
+ }
785
+ });
786
+ }
787
+ };
788
+ };
789
+ var describeToolOf = ({
790
+ names,
791
+ find,
792
+ visibleTools
793
+ }) => {
794
+ return {
795
+ name: names.describe,
796
+ description: `Get the full definition of tools found with "${names.search}", including the input schema "${names.call}" expects.`,
797
+ inputSchema: {
798
+ type: "object",
799
+ properties: {
800
+ names: {
801
+ type: "array",
802
+ items: {
803
+ type: "string"
804
+ },
805
+ minItems: 1,
806
+ description: "Tool names, as search returned them."
807
+ }
808
+ },
809
+ required: ["names"]
810
+ },
811
+ validateArguments: true,
812
+ annotations: {
813
+ readOnlyHint: true
814
+ },
815
+ handler: args => {
816
+ const described = [];
817
+ const unknown = [];
818
+ for (const name of args.names) {
819
+ const tool = find(name);
820
+ if (!tool) {
821
+ unknown.push({
822
+ name,
823
+ suggestions: suggestionsFor({
824
+ name,
825
+ tools: visibleTools()
826
+ })
827
+ });
828
+ continue;
829
+ }
830
+ described.push({
831
+ name: tool.name,
832
+ ...(tool.title ? {
833
+ title: tool.title
834
+ } : {}),
835
+ description: tool.description,
836
+ inputSchema: resolveToolSchemas(tool).inputJson,
837
+ ...(tool.annotations ? {
838
+ annotations: tool.annotations
839
+ } : {})
840
+ });
841
+ }
842
+ return jsonResult({
843
+ data: {
844
+ tools: described,
845
+ ...(unknown.length > 0 ? {
846
+ unknown
847
+ } : {})
848
+ },
849
+ isError: described.length === 0
850
+ });
851
+ }
852
+ };
853
+ };
854
+ /** Runs one tool the way a direct call would, with errors that help a retry. */
855
+ var runTool = async ({
856
+ tool,
857
+ args
858
+ }) => {
859
+ const schemas = resolveToolSchemas(tool);
860
+ const validated = await schemas.input["~standard"].validate(args);
861
+ if (validated.issues) return jsonResult({
862
+ data: {
863
+ error: `Invalid arguments for "${tool.name}".`,
864
+ issues: validated.issues.map(formatIssue),
865
+ inputSchema: schemas.inputJson
866
+ },
867
+ isError: true
868
+ });
869
+ const schemaHint = {
870
+ type: "text",
871
+ text: JSON.stringify({
872
+ inputSchema: schemas.inputJson
873
+ })
874
+ };
875
+ try {
876
+ const result = await tool.handler(validated.value);
877
+ return result.isError ? {
878
+ ...result,
879
+ content: [...result.content, schemaHint]
880
+ } : result;
881
+ } catch (error) {
882
+ return {
883
+ content: [{
884
+ type: "text",
885
+ text: errorMessageOf(error)
886
+ }, schemaHint],
887
+ isError: true
888
+ };
889
+ }
890
+ };
891
+ var callToolOf = ({
892
+ names,
893
+ find,
894
+ visibleTools
895
+ }) => {
896
+ return {
897
+ name: names.call,
898
+ description: `Run a tool found with "${names.search}". Pass its arguments as "${names.describe}" defined them.`,
899
+ inputSchema: {
900
+ type: "object",
901
+ properties: {
902
+ name: {
903
+ type: "string",
904
+ description: "The tool to run."
905
+ },
906
+ arguments: {
907
+ type: "object",
908
+ description: "The tool's arguments, matching its input schema."
909
+ }
910
+ },
911
+ required: ["name"]
912
+ },
913
+ validateArguments: true,
914
+ handler: args => {
915
+ const name = args.name;
916
+ const tool = find(name);
917
+ if (!tool) return jsonResult({
918
+ data: {
919
+ error: `Unknown tool "${name}".`,
920
+ suggestions: suggestionsFor({
921
+ name,
922
+ tools: visibleTools()
923
+ })
924
+ },
925
+ isError: true
926
+ });
927
+ return runTool({
928
+ tool,
929
+ args: args.arguments ?? {}
930
+ });
931
+ }
932
+ };
933
+ };
934
+ /**
935
+ * Defers `tools` behind three tools — `search`, `describe`, `call` — so a
936
+ * client's context holds three small definitions instead of every schema,
937
+ * and a schema enters it only for the tools the model is about to use.
938
+ */
939
+ var registerDeferredTools = ({
940
+ server,
941
+ tools,
942
+ ...options
943
+ }) => {
944
+ const deferred = deferredToolsOf({
945
+ tools,
946
+ options
947
+ });
948
+ for (const tool of [searchToolOf(deferred), describeToolOf(deferred), callToolOf(deferred)]) registerDirectTool({
949
+ server,
950
+ tool
951
+ });
952
+ const except = options.except ?? linksAppView;
953
+ for (const tool of tools) if (except({
954
+ tool
955
+ })) registerDirectTool({
956
+ server,
957
+ tool
958
+ });
959
+ };
960
+
961
+ //#endregion
962
+ //#region src/registerTools.ts
963
+ /**
964
+ * Registers tools on an MCP server, either one MCP tool each (the default)
965
+ * or deferred behind `search` / `describe` / `call`. The same `tools` can feed
966
+ * both, so two endpoints over one API cannot drift apart.
967
+ *
968
+ * @example
969
+ * ```typescript
970
+ * import { McpServer, registerTools, z } from '@ttoss/http-server-mcp';
971
+ *
972
+ * const tools = [
973
+ * {
974
+ * name: 'get-project',
975
+ * description: 'Get a project by ID',
976
+ * inputSchema: z.object({ id: z.string() }),
977
+ * handler: async ({ id }) => ({
978
+ * content: [{ type: 'text', text: `Project: ${id}` }],
979
+ * }),
980
+ * },
981
+ * ];
982
+ *
983
+ * registerTools({ server: fullServer, tools });
984
+ * registerTools({ server: deferredServer, tools, defer: true });
985
+ * ```
986
+ */
987
+ var registerTools = ({
988
+ server,
989
+ tools,
990
+ defer = false
991
+ }) => {
992
+ if (defer === false) {
993
+ for (const tool of tools) registerDirectTool({
994
+ server,
995
+ tool
996
+ });
997
+ return;
998
+ }
999
+ registerDeferredTools({
1000
+ server,
1001
+ tools,
1002
+ ...(defer === true ? {} : defer)
1003
+ });
1004
+ };
1005
+
1006
+ //#endregion
1007
+ //#region src/toolGate.ts
1008
+ /**
472
1009
  * The localized `isError` result for `error`, or `undefined` when it is not
473
1010
  * a `LocalizedError` or rendering fails — the caller then keeps its usual path.
474
1011
  */
@@ -486,76 +1023,84 @@ var localizedToolError = async ({
486
1023
  error,
487
1024
  i18n: await i18n.catalog.getI18n(requested)
488
1025
  });
489
- return rendered ? toolError(rendered.message, rendered.code) : void 0;
1026
+ return rendered ? toolError({
1027
+ message: rendered.message,
1028
+ code: rendered.code
1029
+ }) : void 0;
490
1030
  } catch {
491
1031
  return;
492
1032
  }
493
1033
  };
494
1034
  /**
495
- * Factory that returns a `register` helper for MCP tools that require
496
- * authentication and a specific OAuth scope.
1035
+ * The package's `checkScopes` helper is not reused here because it throws
1036
+ * rather than returning an `isError` result, and it reads identity from
1037
+ * context itself.
1038
+ */
1039
+ var hasScope = ({
1040
+ identity,
1041
+ requiredScope
1042
+ }) => {
1043
+ return (Array.isArray(identity.scopes) ? identity.scopes : []).includes(requiredScope);
1044
+ };
1045
+ /**
1046
+ * Factory for a gate: a function that wraps a {@link Tool} so every call
1047
+ * passes the same authentication and authorization pipeline before its
1048
+ * handler runs. The gated tool is an ordinary `Tool`, so it registers
1049
+ * directly or joins a catalog like any other.
497
1050
  *
498
- * Every registered tool automatically:
1051
+ * Every gated call:
499
1052
  * 1. Resolves the caller identity (default: `getIdentity()`). Returns an
500
1053
  * `isError` result when the identity is absent (unauthenticated request).
501
1054
  * 2. When `enforceScope` is `true` (default), checks `requiredScope` — returns
502
1055
  * an `isError` result when the scope is absent, consistent with how MCP
503
- * surfaces authorization errors. The package's `checkScopes` helper is not
504
- * reused here because it throws rather than returning an `isError` result,
505
- * and it reads identity from context itself.
506
- * 3. Runs global `gates` then per-`register` `def.gates` in order; a throwing
507
- * gate rejects the call. Every gate receives the full {@link ToolCallContext}
508
- * (identity **and** the validated call args), enabling arg-conditional gates.
509
- * Gates own their own error handling; `onError` covers the tool handler only.
1056
+ * surfaces authorization errors.
1057
+ * 3. Runs global `gates` then the tool's `gates` in order; a throwing gate
1058
+ * rejects the call. Every gate receives the full {@link ToolCallContext}.
510
1059
  * 4. Merges `buildContext` output into the handler args.
511
- * 5. Wraps `null`/`undefined` results in a configurable "Not found" error result.
512
- * 6. Calls `onError` on handler throw before rethrowing.
513
- * 7. With `i18n`, renders a `LocalizedError` from a gate or the handler as an
1060
+ * 5. Calls `onError` on handler throw before rethrowing.
1061
+ * 6. With `i18n`, renders a `LocalizedError` from a gate or the handler as an
514
1062
  * `isError` result in the caller's locale instead of rethrowing it.
515
1063
  *
516
1064
  * @example
517
1065
  * ```typescript
518
- * const { register } = createGatedToolRegistrar({
519
- * server,
1066
+ * const gate = createToolGate({
520
1067
  * resolveIdentity: () => {
521
1068
  * const jwt = getIdentity<{ sub: string; scopes: string[] }>();
522
1069
  * return { userId: jwt!.sub, scopes: jwt!.scopes };
523
1070
  * },
524
1071
  * });
525
1072
  *
526
- * register({
527
- * name: 'activate-campaign',
528
- * description: 'Activate or deactivate a campaign.',
529
- * requiredScope: 'campaigns:write',
530
- * inputSchema: { isActive: z.boolean() },
531
- * // arg-conditional gate: active vs. inactive have different restrictions
532
- * gates: [
533
- * ({ args }) =>
534
- * args.isActive
535
- * ? subscriptionGate(['mustNotExceedMaxActive', 'mustHaveBudget'])
536
- * : subscriptionGate(['mustIncludeService']),
1073
+ * registerTools({
1074
+ * server,
1075
+ * tools: [
1076
+ * gate({
1077
+ * requiredScope: 'campaigns:write',
1078
+ * // arg-conditional gate: active vs. inactive have different restrictions
1079
+ * gates: [
1080
+ * ({ args }) =>
1081
+ * args.isActive
1082
+ * ? subscriptionGate(['mustNotExceedMaxActive', 'mustHaveBudget'])
1083
+ * : subscriptionGate(['mustIncludeService']),
1084
+ * ],
1085
+ * tool: defineTool({
1086
+ * name: 'activate-campaign',
1087
+ * description: 'Activate or deactivate a campaign.',
1088
+ * inputSchema: z.object({ isActive: z.boolean() }),
1089
+ * method: ({ userId, isActive }) => toggleCampaign(userId, isActive),
1090
+ * }),
1091
+ * }),
537
1092
  * ],
538
- * method: async ({ userId, isActive }) => toggleCampaign(userId, isActive),
539
1093
  * });
540
1094
  * ```
541
1095
  */
542
- var checkScope = (identity, requiredScope) => {
543
- if (!(Array.isArray(identity.scopes) ? identity.scopes : []).includes(requiredScope)) return toolError(`This tool requires the "${requiredScope}" scope.`);
544
- return null;
545
- };
546
- var runGates = async (allGates, ctx) => {
547
- for (const gate of allGates) await gate(ctx);
548
- };
549
- var createGatedToolRegistrar = ({
550
- server,
1096
+ var createToolGate = ({
551
1097
  resolveIdentity = getIdentity,
552
1098
  gates = [],
553
1099
  enforceScope = true,
554
1100
  onError,
555
1101
  buildContext,
556
- notFoundMessage = "Not found",
557
1102
  i18n
558
- }) => {
1103
+ } = {}) => {
559
1104
  const renderOrRethrow = async (error, ctx) => {
560
1105
  const localized = i18n ? await localizedToolError({
561
1106
  error,
@@ -565,60 +1110,46 @@ var createGatedToolRegistrar = ({
565
1110
  if (localized) return localized;
566
1111
  throw error;
567
1112
  };
568
- const runMethod = async (def, ctx) => {
569
- const extra = buildContext ? buildContext(ctx) : {};
570
- try {
571
- const result = await def.method({
572
- ...ctx.args,
573
- ...extra
574
- });
575
- if (result == null) return toolError(notFoundMessage);
576
- const content = [{
577
- type: "text",
578
- text: JSON.stringify(result)
579
- }];
580
- return def.outputSchema === void 0 ? {
581
- content
582
- } : {
583
- content,
584
- structuredContent: result
585
- };
586
- } catch (error) {
587
- if (onError) await onError(error, ctx);
588
- return renderOrRethrow(error, ctx);
589
- }
590
- };
591
- const register = def => {
592
- const handler = async args => {
593
- const identity = resolveIdentity();
594
- if (!identity) return toolError("Unauthorized");
595
- const ctx = {
596
- identity,
597
- args,
598
- handler: def.name
599
- };
600
- if (enforceScope) {
601
- const scopeError = checkScope(identity, def.requiredScope);
602
- if (scopeError) return scopeError;
603
- }
604
- try {
605
- await runGates([...gates, ...(def.gates ?? [])], ctx);
606
- } catch (error) {
607
- return renderOrRethrow(error, ctx);
1113
+ return ({
1114
+ tool,
1115
+ requiredScope,
1116
+ gates: toolGates = []
1117
+ }) => {
1118
+ return {
1119
+ ...tool,
1120
+ handler: async args => {
1121
+ const identity = resolveIdentity();
1122
+ if (!identity) return toolError({
1123
+ message: "Unauthorized"
1124
+ });
1125
+ const ctx = {
1126
+ identity,
1127
+ args,
1128
+ handler: tool.name
1129
+ };
1130
+ if (enforceScope && !hasScope({
1131
+ identity,
1132
+ requiredScope
1133
+ })) return toolError({
1134
+ message: `This tool requires the "${requiredScope}" scope.`
1135
+ });
1136
+ try {
1137
+ for (const gate of [...gates, ...toolGates]) await gate(ctx);
1138
+ } catch (error) {
1139
+ return renderOrRethrow(error, ctx);
1140
+ }
1141
+ const extra = buildContext ? buildContext(ctx) : {};
1142
+ try {
1143
+ return await tool.handler({
1144
+ ...args,
1145
+ ...extra
1146
+ });
1147
+ } catch (error) {
1148
+ if (onError) await onError(error, ctx);
1149
+ return renderOrRethrow(error, ctx);
1150
+ }
608
1151
  }
609
- return runMethod(def, ctx);
610
1152
  };
611
- server.registerTool(def.name, {
612
- title: def.title,
613
- description: def.description,
614
- inputSchema: def.inputSchema,
615
- outputSchema: def.outputSchema,
616
- annotations: def.annotations,
617
- _meta: def._meta
618
- }, handler);
619
- };
620
- return {
621
- register
622
1153
  };
623
1154
  };
624
1155
 
@@ -991,4 +1522,4 @@ var createMcpRouter = (server, options = {}) => {
991
1522
  };
992
1523
 
993
1524
  //#endregion
994
- export { InMemoryServerEventBus, McpServer, UI_EXTENSION_ID, UI_RESOURCE_MIME_TYPE, apiCall, checkScopes, createGatedToolRegistrar, createMcpRouter, errorBodyMessage, getApiHeaders, getIdentity, getRequestLocale, registerAppResource, registerToolFromSchema, z };
1525
+ export { InMemoryServerEventBus, McpServer, UI_EXTENSION_ID, UI_RESOURCE_MIME_TYPE, apiCall, checkScopes, createMcpRouter, createToolGate, defineTool, errorBodyMessage, getApiHeaders, getIdentity, getRequestLocale, rankTools, registerAppResource, registerTools, z };