@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/MIGRATIONS.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # Migrations
2
2
 
3
+ ## One `Tool` type: `registerTools` replaces `registerToolFromSchema` and `createGatedToolRegistrar`
4
+
5
+ A tool is now one value, `Tool`, and registering is one call, `registerTools`,
6
+ which can also [defer](./README.md#deferring-tools-for-large-tool-sets) them
7
+ behind `search` / `describe` / `call`. Gating became a function from `Tool` to
8
+ `Tool`, so a gated tool is deferred like any other.
9
+
10
+ **`registerToolFromSchema`** — wrap the params in a `tools` list:
11
+
12
+ ```diff
13
+ -registerToolFromSchema(server, {
14
+ - name: 'get-project',
15
+ - description: 'Get a project',
16
+ - inputSchema,
17
+ - handler,
18
+ -});
19
+ +registerTools({
20
+ + server,
21
+ + tools: [{ name: 'get-project', description: 'Get a project', inputSchema, handler }],
22
+ +});
23
+ ```
24
+
25
+ `description` is now required. `validateArguments` and `_meta` keep their
26
+ meaning, as fields of the tool.
27
+
28
+ **`createGatedToolRegistrar`** — the registrar options become gate options
29
+ (minus `server` and `notFoundMessage`), and each `register(def)` becomes a
30
+ gated `defineTool`:
31
+
32
+ ```diff
33
+ -const { register } = createGatedToolRegistrar({ server, resolveIdentity, gates });
34
+ +const gate = createToolGate({ resolveIdentity, gates });
35
+
36
+ -register({
37
+ - name: 'list-campaigns',
38
+ - description: 'List campaigns.',
39
+ - requiredScope: 'campaigns:read',
40
+ - inputSchema: { limit: z.number().optional() },
41
+ - method: ({ limit }) => fetchCampaigns(limit),
42
+ -});
43
+ +registerTools({
44
+ + server,
45
+ + tools: [
46
+ + gate({
47
+ + requiredScope: 'campaigns:read',
48
+ + tool: defineTool({
49
+ + name: 'list-campaigns',
50
+ + description: 'List campaigns.',
51
+ + inputSchema: z.object({ limit: z.number().optional() }),
52
+ + method: ({ limit }) => fetchCampaigns(limit),
53
+ + }),
54
+ + }),
55
+ + ],
56
+ +});
57
+ ```
58
+
59
+ - **`inputSchema` and `outputSchema` take `z.object({...})`**, not a Zod field
60
+ map: a schema is now a JSON Schema or a Standard Schema. Wrap
61
+ `{ limit: z.number() }` as `z.object({ limit: z.number() })`.
62
+ - **`notFoundMessage` moved to `defineTool`**, per tool. A registrar-wide value
63
+ is now a wrapper around `defineTool` that sets it.
64
+ - **Per-tool `gates` move to the gate call**, next to `requiredScope`.
65
+ - `GatedToolDef` and `CreateGatedToolRegistrarOptions` are gone;
66
+ `CreateToolGateOptions`, `ToolCallGate`, `ToolCallContext` and
67
+ `ToolIdentity` remain.
68
+
69
+ **What you will observe if you miss this.** The build fails: every removed
70
+ export is a missing import, and a Zod field map passed as `inputSchema` is a
71
+ type error. Nothing changes at runtime for code that compiles — results,
72
+ scope refusals, gate order and localized errors are byte-for-byte the same.
73
+
3
74
  ## The `2026-07-28` revision needs `createMcpServer`
4
75
 
5
76
  That revision is served only when `createMcpRouter` is given a
package/README.md CHANGED
@@ -12,7 +12,12 @@ pnpm add @ttoss/http-server-mcp
12
12
 
13
13
  ```typescript
14
14
  import { App, bodyParser, cors } from '@ttoss/http-server';
15
- import { createMcpRouter, McpServer, z } from '@ttoss/http-server-mcp';
15
+ import {
16
+ createMcpRouter,
17
+ McpServer,
18
+ registerTools,
19
+ z,
20
+ } from '@ttoss/http-server-mcp';
16
21
 
17
22
  // Create MCP server
18
23
  const mcpServer = new McpServer({
@@ -21,23 +26,21 @@ const mcpServer = new McpServer({
21
26
  });
22
27
 
23
28
  // Register tools
24
- mcpServer.registerTool(
25
- 'get-weather',
26
- {
27
- description: 'Get weather information for a location',
28
- inputSchema: {
29
- location: z.string().describe('City name'),
29
+ registerTools({
30
+ server: mcpServer,
31
+ tools: [
32
+ {
33
+ name: 'get-weather',
34
+ description: 'Get weather information for a location',
35
+ inputSchema: z.object({ location: z.string().describe('City name') }),
36
+ handler: async ({ location }) => ({
37
+ content: [
38
+ { type: 'text', text: `Weather in ${location}: Sunny, 72°F` },
39
+ ],
40
+ }),
30
41
  },
31
- },
32
- async ({ location }) => ({
33
- content: [
34
- {
35
- type: 'text',
36
- text: `Weather in ${location}: Sunny, 72°F`,
37
- },
38
- ],
39
- })
40
- );
42
+ ],
43
+ });
41
44
 
42
45
  // Create HTTP server
43
46
  const app = new App();
@@ -435,29 +438,83 @@ createMcpRouter(mcpServer, {
435
438
 
436
439
  The discovery endpoints (both locations above) remain publicly accessible even when `aliases` includes `'/'`. Aliases themselves get no derived location — the metadata `resource` always names the primary `path`.
437
440
 
438
- ## Gated Tool Registrar
441
+ ## Tools
442
+
443
+ A `Tool` is what a tool is and how it answers, independent of how a server exposes it: `name`, `description`, `inputSchema`, `handler`, and optionally `title`, `outputSchema`, `annotations`, `tags`, `summary` and `_meta`. `registerTools` registers a list of them.
444
+
445
+ ```typescript
446
+ import { defineTool, registerTools, z } from '@ttoss/http-server-mcp';
447
+
448
+ registerTools({
449
+ server,
450
+ tools: [
451
+ {
452
+ name: 'get-project',
453
+ description: 'Get a project by ID',
454
+ inputSchema: z.object({ id: z.string() }),
455
+ handler: async ({ id }) => ({
456
+ content: [{ type: 'text', text: `Project: ${id}` }],
457
+ }),
458
+ },
459
+ // defineTool: answer with data; null answers "Not found" as an error.
460
+ defineTool({
461
+ name: 'list-campaigns',
462
+ description: 'List the caller campaigns.',
463
+ inputSchema: z.object({ limit: z.number().optional() }),
464
+ method: ({ limit }) => fetchCampaigns(limit),
465
+ }),
466
+ ],
467
+ });
468
+ ```
469
+
470
+ `inputSchema` is either a Standard Schema (Zod 4's `z.object(...)`), always enforced, with the handler receiving its parsed output; or a plain JSON Schema, forwarded verbatim and enforced only with `validateArguments` — see [Argument validation](#argument-validation). A plain JSON Schema keeps one definition shareable with an AI SDK agent, whose `tool()` helper takes the same object.
471
+
472
+ ### Deferring tools for large tool sets
473
+
474
+ Every tool definition ships to the model on every turn, so a few hundred tools overflow a client's context, and some providers cap the number of tools per request. `defer` registers the same tools behind three that load them on demand:
439
475
 
440
- `createGatedToolRegistrar` wraps `server.registerTool` with a consistent authentication and authorization pipeline so you don't repeat the same boilerplate in every handler.
476
+ | Tool | Input | Returns |
477
+ | ---------- | ------------------------- | ---------------------------------------------------------------------------------------------- |
478
+ | `search` | `query`, `tag?`, `limit?` | Name, one-line `summary` and `tags` of the best matches |
479
+ | `describe` | `names` | Each tool's full definition, including `inputSchema`; unknown names come back with suggestions |
480
+ | `call` | `name`, `arguments` | Exactly what the tool answers |
441
481
 
442
- Every registered tool automatically:
482
+ ```typescript
483
+ registerTools({ server: fullServer, tools });
484
+ registerTools({ server: deferredServer, tools, defer: true });
485
+ ```
486
+
487
+ A schema enters the model's context only for the tools it is about to use. `call` enforces the tool's schema as a direct call would, and its errors help the model recover: an unknown name answers suggestions, and invalid arguments, a tool's own error result, or a thrown error carry the input schema, so a `call` made without `describe` corrects itself in one retry.
488
+
489
+ Pass options instead of `true` to tune it:
490
+
491
+ - `except` — tools kept standalone, not deferred; they stay reachable through `search` too. Defaults to those linked to an [MCP Apps](#mcp-apps-interactive-uis) view, since a host finds a view through the tool's own `tools/list` entry.
492
+ - `visible` — runs on every `search`, `describe` and `call`; a tool it rejects is absent from all three. Standalone tools stay listed.
493
+ - `search` — replaces the default ranking, `rankTools`, e.g. with embeddings.
494
+ - `searchLimit` (default `10`) and `names` (default `search` / `describe` / `call`).
495
+
496
+ A gate (below) sees each call with the tool's own name and arguments, so authorization stays per tool when deferred.
497
+
498
+ ### Gating tools
499
+
500
+ `createToolGate` returns a gate: a function that wraps a `Tool` so every call passes the same authentication and authorization pipeline before its handler runs. The gated tool is an ordinary `Tool`.
443
501
 
444
502
  1. Resolves the caller's identity (defaults to `getIdentity()` from the request context).
445
- 2. Checks that the caller holds a required OAuth scope — returns an `isError` result (not a throw) when the scope is absent.
446
- 3. Runs global `gates` then per-tool `def.gates` in order; a throwing gate rejects the call. Both receive `ToolCallContext` (identity **and** the validated call args).
503
+ 2. Checks that the caller holds the tool's `requiredScope` — returns an `isError` result (not a throw) when the scope is absent.
504
+ 3. Runs global `gates` then the tool's `gates` in order; a throwing gate rejects the call. Both receive `ToolCallContext` (identity **and** the validated call args).
447
505
  4. Merges `buildContext` output into the handler args.
448
- 5. Wraps `null`/`undefined` results in a configurable "Not found" error result.
449
- 6. Calls `onError` on handler throw before rethrowing, for telemetry. Gate/scope failures do **not** trigger `onError`.
506
+ 5. Calls `onError` on handler throw before rethrowing, for telemetry. Gate/scope failures do **not** trigger `onError`.
450
507
 
451
508
  ```typescript
452
509
  import {
453
- createGatedToolRegistrar,
510
+ createToolGate,
511
+ defineTool,
454
512
  getIdentity,
455
- McpServer,
513
+ registerTools,
514
+ z,
456
515
  } from '@ttoss/http-server-mcp';
457
- import { z } from 'zod';
458
516
 
459
- const { register } = createGatedToolRegistrar({
460
- server,
517
+ const gate = createToolGate({
461
518
  resolveIdentity: () => {
462
519
  const jwt = getIdentity<{ sub: string; scope: string }>();
463
520
  const scopes = jwt?.scope?.split(' ') ?? [];
@@ -469,47 +526,39 @@ const { register } = createGatedToolRegistrar({
469
526
  },
470
527
  });
471
528
 
472
- register({
473
- name: 'list-campaigns',
474
- description: 'List all ad campaigns for the caller.',
475
- requiredScope: 'campaigns:read',
476
- inputSchema: { limit: z.number().optional() },
477
- method: async ({ userId, tenantId, limit }) =>
478
- fetchCampaigns(tenantId, limit),
479
- });
480
- ```
481
-
482
- **`resolveIdentity`** is called once per invocation. Omit it to use the default `getIdentity()` from the MCP request context.
483
-
484
- **`gates`** (global and per-tool) are async functions that receive a `ToolCallContext` — both the resolved identity and the validated call args — and throw to reject. The full context lets gates vary their predicate based on what the caller is asking for, not just who they are.
485
-
486
- ```typescript
487
- register({
488
- name: 'activate-ad-account',
489
- description: 'Activate or deactivate an ad account.',
490
- requiredScope: 'accounts:write',
491
- inputSchema: { accountId: z.number(), isActive: z.boolean() },
492
- // arg-conditional gate: activating requires more checks than deactivating
493
- gates: [
494
- ({ args }) =>
495
- args.isActive
496
- ? checkSubscriptionGates(['mustNotExceedMaxActive', 'mustHaveBudget'])
497
- : checkSubscriptionGates(['mustIncludeService']),
529
+ registerTools({
530
+ server,
531
+ tools: [
532
+ gate({
533
+ requiredScope: 'accounts:write',
534
+ // arg-conditional gate: activating requires more checks than deactivating
535
+ gates: [
536
+ ({ args }) =>
537
+ args.isActive
538
+ ? checkSubscriptionGates([
539
+ 'mustNotExceedMaxActive',
540
+ 'mustHaveBudget',
541
+ ])
542
+ : checkSubscriptionGates(['mustIncludeService']),
543
+ ],
544
+ tool: defineTool({
545
+ name: 'activate-ad-account',
546
+ description: 'Activate or deactivate an ad account.',
547
+ inputSchema: z.object({ accountId: z.number(), isActive: z.boolean() }),
548
+ method: ({ tenantId, accountId, isActive }) =>
549
+ toggleAdAccount(tenantId, accountId, isActive),
550
+ }),
551
+ }),
498
552
  ],
499
- method: async ({ userId, accountId, isActive }) =>
500
- toggleAdAccount(userId, accountId, isActive),
501
553
  });
502
554
  ```
503
555
 
504
- **`buildContext`** injects request-scoped values (DB clients, tenant IDs) into every handler call without threading them through each individual tool. It receives the full `ToolCallContext` so context can vary by identity or by call args.
505
-
506
556
  ### Localized refusals and errors
507
557
 
508
- Pass `i18n` to render a [`LocalizedError`](https://ttoss.dev/docs/modules/packages/i18n-core/) thrown by a **gate** or by the **handler** as an `isError` result `{ "error": "<message>", "code": "<CODE>" }` in the caller's locale. Gates run outside the handler, so the registrar is the one place that covers both.
558
+ Pass `i18n` to render a [`LocalizedError`](https://ttoss.dev/docs/modules/packages/i18n-core/) thrown by a **gate** or by the **handler** as an `isError` result `{ "error": "<message>", "code": "<CODE>" }` in the caller's locale. Gates run outside the handler, so the gate is the one place that covers both.
509
559
 
510
560
  ```ts
511
- const { register } = createGatedToolRegistrar({
512
- server,
561
+ const gate = createToolGate({
513
562
  gates: [subscriptionGate],
514
563
  i18n: {
515
564
  catalog,
@@ -558,36 +607,36 @@ const dashboard = registerAppResource({
558
607
  },
559
608
  });
560
609
 
561
- server.registerTool(
562
- 'get-weather',
563
- {
564
- description: 'Get the weather for a location',
565
- inputSchema: { location: z.string() },
566
- _meta: dashboard.toolMeta(),
567
- },
568
- async ({ location }) => {
569
- const forecast = await fetchForecast(location);
570
- return {
571
- // Text stays meaningful: it is what a host without Apps renders.
572
- content: [{ type: 'text', text: summarise(forecast) }],
573
- structuredContent: forecast,
574
- };
575
- }
576
- );
577
- ```
578
-
579
- `toolMeta()` is the linkage for **every** registration path — `registerTool`, [`registerToolFromSchema`](#registertoolfromschemaserver-params), and a [`GatedToolDef`](#creategatedtoolregistraroptions) all take `_meta`:
580
-
581
- ```typescript
582
- registerToolFromSchema(server, {
583
- name: 'refresh-dashboard',
584
- description: 'Refresh dashboard data',
585
- // Callable by the view, hidden from the model.
586
- _meta: dashboard.toolMeta({ visibility: ['app'] }),
587
- handler: async () => ({ content: [{ type: 'text', text: 'refreshed' }] }),
610
+ registerTools({
611
+ server,
612
+ tools: [
613
+ {
614
+ name: 'get-weather',
615
+ description: 'Get the weather for a location',
616
+ inputSchema: z.object({ location: z.string() }),
617
+ _meta: dashboard.toolMeta(),
618
+ handler: async ({ location }) => {
619
+ const forecast = await fetchForecast(location);
620
+ return {
621
+ // Text stays meaningful: it is what a host without Apps renders.
622
+ content: [{ type: 'text', text: summarise(forecast) }],
623
+ structuredContent: forecast,
624
+ };
625
+ },
626
+ },
627
+ {
628
+ name: 'refresh-dashboard',
629
+ description: 'Refresh dashboard data',
630
+ // Callable by the view, hidden from the model.
631
+ _meta: dashboard.toolMeta({ visibility: ['app'] }),
632
+ handler: async () => ({ content: [{ type: 'text', text: 'refreshed' }] }),
633
+ },
634
+ ],
588
635
  });
589
636
  ```
590
637
 
638
+ `toolMeta()` is the linkage for every registration path: a `Tool`'s `_meta`, or the SDK's `registerTool`. When [deferred](#deferring-tools-for-large-tool-sets), view-linked tools stay registered standalone by default.
639
+
591
640
  ### Register the linkage unconditionally
592
641
 
593
642
  The spec suggests checking the client's declared capability before registering UI-enabled tools. Do not: that read is only reliable on the `2026-07-28` revision, where the capability arrives in each request's envelope. In this package's default [stateless mode](#stateless-vs-stateful-mode) there is no remembered `initialize` for a 2025-era client, so a capability-gated registration silently drops the view for every one of them.
@@ -705,41 +754,62 @@ Returns the `WWW-Authenticate` header value for a 401 response, formatted per th
705
754
 
706
755
  **Returns:** `string` — The full `WWW-Authenticate` header value
707
756
 
708
- ### `createGatedToolRegistrar(options)`
757
+ ### `registerTools(params)`
709
758
 
710
- Factory that returns a `register` helper for tools that require authentication and a specific OAuth scope.
759
+ Registers `Tool`s on an MCP server. See [Tools](#tools).
711
760
 
712
- **Parameters (`options`):**
761
+ **Parameters (`params`):**
713
762
 
714
- - `server` (`McpServer`) — The MCP server to register tools on.
715
- - `resolveIdentity` (`() => ToolIdentity`, optional) — Called once per invocation to resolve `{ userId, scopes? }`. Defaults to `getIdentity()` from the request context.
716
- - `gates` (`Array<(ctx: ToolCallContext) => void | Promise<void>>`, optional) — Global guards run after the scope check, in order, before any per-tool gates. Each receives `{ identity, args, handler }`. Throw to reject the call.
717
- - `enforceScope` (`boolean`, optional, default `true`) — When `true`, checks `def.requiredScope` against `identity.scopes` and returns an `isError` result on mismatch. Set to `false` when all authorization is handled by `gates`.
718
- - `buildContext` (`(ctx: ToolCallContext) => Record<string, unknown>`, optional) — Produces extra key-value pairs merged into every handler's args. Receives the full `ToolCallContext` so context can vary by identity or by call args.
719
- - `onError` (`(error, ctx: ToolCallContext) => void | Promise<void>`, optional) — Called when the handler throws, before the error is rethrown. Gate/scope failures do **not** trigger this hook.
720
- - `notFoundMessage` (`string`, optional, default `"Not found"`) — `isError` message returned when the handler resolves to `null`/`undefined`.
763
+ - `server` (`McpServer`) — The MCP server to register on.
764
+ - `tools` (`Tool[]`) — The tools.
765
+ - `defer` (`boolean | DeferToolsOptions`, optional, default `false`) — Defer the tools behind `search` / `describe` / `call`; see [Deferring tools for large tool sets](#deferring-tools-for-large-tool-sets) for the options.
766
+
767
+ **`Tool`:**
768
+
769
+ - `name` (`string`) — Unique tool name.
770
+ - `description` (`string`) — What the tool does, written for the model.
771
+ - `inputSchema` (`JsonObjectSchema | StandardSchemaWithJSON`, optional) — Defaults to `{ type: 'object', properties: {} }`.
772
+ - `validateArguments` (`boolean`, optional, default `false`) — Enforce a JSON Schema `inputSchema`; see [Argument validation](#argument-validation).
773
+ - `outputSchema` (same types as `inputSchema`, optional) — Advertised on `tools/list`; `structuredContent` is validated against it.
774
+ - `title`, `annotations`, `_meta` (optional) — Forwarded on `tools/list`.
775
+ - `tags`, `summary` (optional) — Read by the deferred `search`; never sent on `tools/list`.
776
+ - `handler` (`(args) => CallToolResult | Promise<CallToolResult>`) — Answers a call.
777
+
778
+ **Returns:** `void`
779
+
780
+ #### Argument validation
781
+
782
+ By default, a JSON Schema `inputSchema` is **advertised but not enforced**: it round-trips verbatim over `tools/list` so clients know what to send, while arguments reach your handler unchecked. Set `validateArguments: true` to reject mismatched calls before the handler runs.
721
783
 
722
- **Returns:** `{ register: (def: GatedToolDef) => void }`
784
+ Enable it once you're confident `inputSchema` describes every value the tool genuinely accepts. Schemas derived from an OpenAPI document are a common source of _incomplete_ ones — a field a client may send as `null` to clear it, or one that accepts several shapes, is easily emitted as a bare `{ type: 'string' }`. Validating against a schema like that rejects calls the underlying API would have accepted. To describe those cases accurately, use `{ type: ['string', 'null'] }` for a nullable field and forward `oneOf`/`anyOf` verbatim rather than collapsing to one type.
785
+
786
+ ### `defineTool(params)`
787
+
788
+ Builds a `Tool` from a `method` that returns data: JSON text, plus `structuredContent` when `outputSchema` is set. `null`/`undefined` answers `notFoundMessage` (default `"Not found"`) as an `isError` result. Takes every `Tool` field but `handler`.
723
789
 
724
- The `GatedToolDef` passed to `register` has:
790
+ ### `createToolGate(options?)`
725
791
 
726
- - `name` — Tool name.
727
- - `description` — Human-readable description.
728
- - `requiredScope` — Single scope that must appear in `identity.scopes`.
729
- - `inputSchema` — Zod field map or `ZodObject`, forwarded to `server.registerTool`.
730
- - `gates` (`Array<(ctx: ToolCallContext) => void | Promise<void>>`, optional) — Per-tool guards appended after the global `gates`. Receive the full `ToolCallContext` enabling arg-conditional authorization.
731
- - `_meta` (`Record<string, unknown>`, optional) — Tool metadata forwarded verbatim on `tools/list`; how a gated tool links to an [MCP Apps](#mcp-apps-interactive-uis) view.
732
- - `title` (`string`, optional) — Display name forwarded on `tools/list`.
733
- - `annotations` (`ToolAnnotations`, optional) — Behaviour hints forwarded on `tools/list`: `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`. Clients use them to decide when to ask for confirmation; they never replace `requiredScope` or `gates`.
734
- - `outputSchema` (Zod field map or `ZodObject`, optional) — Result schema forwarded on `tools/list`. When set, a successful result is returned as `structuredContent` **and** as the same JSON in a `TextContent` block, and the SDK validates it against the schema. Without it, the result is a single `TextContent`, as before.
735
- - `method` — Async handler. Receives merged call args + `buildContext` output.
792
+ Returns a gate, `({ tool, requiredScope, gates? }) => Tool`, that wraps a tool in the pipeline described in [Gating tools](#gating-tools).
793
+
794
+ **Parameters (`options`):**
795
+
796
+ - `resolveIdentity` (`() => ToolIdentity`, optional) — Called once per invocation to resolve `{ userId, scopes? }`. Defaults to `getIdentity()` from the request context.
797
+ - `gates` (`ToolCallGate[]`, optional) — Global guards run after the scope check, in order, before any per-tool gates. Each receives `{ identity, args, handler }`. Throw to reject the call.
798
+ - `enforceScope` (`boolean`, optional, default `true`) — When `true`, checks `requiredScope` against `identity.scopes` and returns an `isError` result on mismatch. Set to `false` when all authorization is handled by `gates`.
799
+ - `buildContext` (`(ctx: ToolCallContext) => Record<string, unknown>`, optional) — Produces extra key-value pairs merged into every handler's args.
800
+ - `onError` (`(error, ctx: ToolCallContext) => void | Promise<void>`, optional) — Called when the handler throws, before the error is rethrown. Gate/scope failures do **not** trigger this hook.
801
+ - `i18n` (optional) — See [Localized refusals and errors](#localized-refusals-and-errors).
736
802
 
737
803
  **`ToolCallContext`** is the object passed to gates, `buildContext`, and `onError`:
738
804
 
739
805
  - `identity` (`ToolIdentity`) — The resolved caller identity (`{ userId, scopes? }`).
740
- - `args` (`Record<string, unknown>`) — The validated tool input (post SDK parse).
806
+ - `args` (`Record<string, unknown>`) — The validated tool input.
741
807
  - `handler` (`string`) — The tool name, for error attribution.
742
808
 
809
+ ### `rankTools(args)`
810
+
811
+ The deferred `search`'s default ranking, exported as a fallback for a custom one: scores `tools` by the terms of `query` — weighted name > summary > tags > description, plural and singular alike, a word's prefix at half weight — filters by `tag`, and returns up to `limit`, ties in the tools' order.
812
+
743
813
  ### `registerAppResource(params)`
744
814
 
745
815
  Registers an [MCP Apps](#mcp-apps-interactive-uis) view: a `ui://` resource whose HTML a host renders for a linked tool's result.
@@ -767,65 +837,11 @@ Registers an [MCP Apps](#mcp-apps-interactive-uis) view: a `ui://` resource whos
767
837
 
768
838
  `'io.modelcontextprotocol/ui'` and `'text/html;profile=mcp-app'`. Use the first as the `capabilities.extensions` key when advertising Apps support on the `McpServer`, and the second as the MIME type a host negotiates.
769
839
 
770
- ### `registerToolFromSchema(server, params)`
771
-
772
- Registers a tool using a **plain JSON Schema** object for `inputSchema` instead of a Zod shape.
773
-
774
- Use this when tool definitions are shared between the MCP server and an AI SDK agent (e.g. Vercel AI SDK's `tool()` helper). Both consumers accept plain JSON Schema at runtime, so a single definition can feed both without any lossy conversion.
775
-
776
- **Parameters:**
777
-
778
- - `server` (`McpServer`) — The MCP server instance
779
- - `params.name` (`string`) — Unique tool name
780
- - `params.description` (`string`, optional) — Human-readable description
781
- - `params.inputSchema` (`JsonObjectSchema`, optional) — Plain JSON Schema object (defaults to `{ type: 'object', properties: {} }`)
782
- - `params.validateArguments` (`boolean`, optional) — Enforce `inputSchema` on `tools/call` (default: `false`); see [Argument validation](#argument-validation)
783
- - `params._meta` (`Record<string, unknown>`, optional) — Tool metadata forwarded verbatim on `tools/list`; how a tool links to an [MCP Apps](#mcp-apps-interactive-uis) view
784
- - `params.handler` (`(args: Record<string, unknown>) => CallToolResult | Promise<CallToolResult>`) — Tool handler receiving the raw request arguments
785
-
786
- **Returns:** `void`
787
-
788
- #### Argument validation
789
-
790
- By default, `inputSchema` is **advertised but not enforced**: it round-trips verbatim over `tools/list` so clients know what to send, while arguments reach your handler unchecked. Pass `validateArguments: true` to reject mismatched calls with an MCP error before the handler runs.
791
-
792
- Enable it once you're confident `inputSchema` describes every value the tool genuinely accepts. Schemas derived from an OpenAPI document are a common source of _incomplete_ ones — a field a client may send as `null` to clear it, or one that accepts several shapes, is easily emitted as a bare `{ type: 'string' }`. Validating against a schema like that rejects calls the underlying API would have accepted. To describe those cases accurately, use `{ type: ['string', 'null'] }` for a nullable field and forward `oneOf`/`anyOf` verbatim rather than collapsing to one type.
793
-
794
840
  ## Examples
795
841
 
796
- ### Plain JSON Schema Tool (`registerToolFromSchema`)
797
-
798
- Use `registerToolFromSchema` when you share tool definitions across the MCP server **and** an AI SDK agent. The plain JSON Schema is forwarded verbatim over the MCP wire protocol — `anyOf`, `$ref`, `pattern`, and other features not supported by Zod v3 are preserved without loss.
799
-
800
- ```typescript
801
- import {
802
- createMcpRouter,
803
- McpServer,
804
- registerToolFromSchema,
805
- } from '@ttoss/http-server-mcp';
806
-
807
- const server = new McpServer({ name: 'my-server', version: '1.0.0' });
808
-
809
- registerToolFromSchema(server, {
810
- name: 'get-project',
811
- description: 'Get a project by ID',
812
- inputSchema: {
813
- type: 'object',
814
- properties: {
815
- id: { type: 'string', description: 'Project public ID' },
816
- // anyOf is preserved — Zod v3 has no direct equivalent
817
- status: { anyOf: [{ type: 'string' }, { type: 'null' }] },
818
- },
819
- required: ['id'],
820
- },
821
- handler: async ({ id }) => {
822
- const data = await apiCall('GET', `/projects/${id}`);
823
- return { content: [{ type: 'text', text: JSON.stringify(data) }] };
824
- },
825
- });
826
- ```
842
+ ### Single source of truth across MCP and AI SDK
827
843
 
828
- **Single source of truth across MCP and AI SDK:**
844
+ A JSON Schema `inputSchema` is forwarded verbatim — `anyOf`, `$ref`, `pattern` survive — so one definition feeds both:
829
845
 
830
846
  ```typescript
831
847
  // lib/tools.ts — shared tool definition
@@ -834,23 +850,29 @@ export const getProjectTool = {
834
850
  description: 'Get a project by ID',
835
851
  inputSchema: {
836
852
  type: 'object' as const,
837
- properties: { id: { type: 'string' } },
853
+ properties: {
854
+ id: { type: 'string' },
855
+ status: { anyOf: [{ type: 'string' }, { type: 'null' }] },
856
+ },
838
857
  required: ['id'],
839
858
  },
840
859
  };
841
860
 
842
861
  // MCP server
843
- import { registerToolFromSchema } from '@ttoss/http-server-mcp';
844
- registerToolFromSchema(mcpServer, {
845
- ...getProjectTool,
846
- handler: async ({ id }) => {
847
- /* ... */
848
- },
862
+ registerTools({
863
+ server: mcpServer,
864
+ tools: [
865
+ {
866
+ ...getProjectTool,
867
+ handler: async ({ id }) => {
868
+ /* ... */
869
+ },
870
+ },
871
+ ],
849
872
  });
850
873
 
851
874
  // AI SDK agent
852
- import { tool } from 'ai';
853
- import { jsonSchema } from 'ai';
875
+ import { jsonSchema, tool } from 'ai';
854
876
  const agentTool = tool({
855
877
  description: getProjectTool.description,
856
878
  parameters: jsonSchema(getProjectTool.inputSchema),