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