@dereekb/firebase-server 13.16.0 → 13.18.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/mcp/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@dereekb/firebase-server/mcp",
3
- "version": "13.16.0",
3
+ "version": "13.18.0",
4
4
  "peerDependencies": {
5
- "@dereekb/analytics": "13.16.0",
6
- "@dereekb/date": "13.16.0",
7
- "@dereekb/firebase": "13.16.0",
8
- "@dereekb/firebase-server": "13.16.0",
9
- "@dereekb/firebase-server/oidc": "13.16.0",
10
- "@dereekb/model": "13.16.0",
11
- "@dereekb/nestjs": "13.16.0",
12
- "@dereekb/rxjs": "13.16.0",
13
- "@dereekb/util": "13.16.0",
14
- "@dereekb/zoho": "13.16.0",
5
+ "@dereekb/analytics": "13.18.0",
6
+ "@dereekb/date": "13.18.0",
7
+ "@dereekb/firebase": "13.18.0",
8
+ "@dereekb/firebase-server": "13.18.0",
9
+ "@dereekb/firebase-server/oidc": "13.18.0",
10
+ "@dereekb/model": "13.18.0",
11
+ "@dereekb/nestjs": "13.18.0",
12
+ "@dereekb/rxjs": "13.18.0",
13
+ "@dereekb/util": "13.18.0",
14
+ "@dereekb/zoho": "13.18.0",
15
15
  "@modelcontextprotocol/sdk": "1.29.0",
16
16
  "@nestjs/common": "^11.1.19",
17
17
  "@nestjs/core": "^11.1.19",
@@ -19,6 +19,66 @@ export declare const DEFAULT_MCP_SERVER_NAME = "dbx-firebase-server-mcp";
19
19
  * handshake. Apps may override this via {@link McpModuleConfig.serverInstructions}.
20
20
  */
21
21
  export declare const DEFAULT_MCP_SERVER_INSTRUCTIONS = "A set of call-model tools generated automatically.";
22
+ /**
23
+ * Default name of the auto-injected reason parameter added to every advertised MCP tool's input
24
+ * schema. See {@link McpReasonParameterConfig.parameterName}.
25
+ */
26
+ export declare const DEFAULT_MCP_REASON_PARAMETER_NAME = "reason";
27
+ /**
28
+ * Default maximum length (in characters) of the auto-injected reason parameter. Values longer than
29
+ * this are clamped server-side before being forwarded to analytics. See
30
+ * {@link McpReasonParameterConfig.maxLength}.
31
+ */
32
+ export declare const DEFAULT_MCP_REASON_PARAMETER_MAX_LENGTH = 250;
33
+ /**
34
+ * Default required-ness of the auto-injected reason parameter. See
35
+ * {@link McpReasonParameterConfig.required}.
36
+ */
37
+ export declare const DEFAULT_MCP_REASON_PARAMETER_REQUIRED = true;
38
+ /**
39
+ * Default description advertised for the auto-injected reason parameter on every tool's input schema.
40
+ * See {@link McpReasonParameterConfig.description}.
41
+ */
42
+ export declare const DEFAULT_MCP_REASON_PARAMETER_DESCRIPTION = "A brief human-readable reason (\u2264250 chars) explaining why this tool is being called. Recorded for analytics/audit only; not part of the operation.";
43
+ /**
44
+ * Configuration for the auto-injected `reason` parameter the MCP server adds to every advertised
45
+ * tool's input schema.
46
+ *
47
+ * When enabled (the default), the server augments each tool's `inputSchema` with a `reason` string
48
+ * property — a short human-readable justification for the call, surfaced to the model and recorded on
49
+ * the per-call analytics event. The field is stripped from the JSON body before the args are
50
+ * dispatched to the underlying handler, so call-model handlers never receive it.
51
+ *
52
+ * Supplied to {@link McpModuleConfig.reasonParameter} as a full object (to tune individual fields), or
53
+ * as a boolean shorthand (`true` = defaults on, `false` = disabled).
54
+ */
55
+ export interface McpReasonParameterConfig {
56
+ /**
57
+ * Whether the reason parameter is injected at all. Defaults to `true`. Set `false` to disable
58
+ * (equivalent to `reasonParameter: false`).
59
+ */
60
+ readonly enabled?: boolean;
61
+ /**
62
+ * Whether the parameter is marked `required` in the advertised input schema. Defaults to
63
+ * {@link DEFAULT_MCP_REASON_PARAMETER_REQUIRED}.
64
+ */
65
+ readonly required?: boolean;
66
+ /**
67
+ * Maximum character length advertised (`maxLength`) and enforced server-side (the forwarded value is
68
+ * clamped). Defaults to {@link DEFAULT_MCP_REASON_PARAMETER_MAX_LENGTH}.
69
+ */
70
+ readonly maxLength?: number;
71
+ /**
72
+ * Description advertised for the parameter on each tool's input schema. Defaults to
73
+ * {@link DEFAULT_MCP_REASON_PARAMETER_DESCRIPTION}.
74
+ */
75
+ readonly description?: string;
76
+ /**
77
+ * Name of the injected parameter. Defaults to {@link DEFAULT_MCP_REASON_PARAMETER_NAME} (`'reason'`).
78
+ * Rename to avoid colliding with a handler that legitimately consumes a `reason` input field.
79
+ */
80
+ readonly parameterName?: string;
81
+ }
22
82
  /**
23
83
  * Configuration for the firebase-server/mcp module.
24
84
  *
@@ -80,6 +140,17 @@ export declare abstract class McpModuleConfig {
80
140
  * schemas, no output schemas) and emits a single boot warning.
81
141
  */
82
142
  readonly mcpManifestPath?: string;
143
+ /**
144
+ * Absolute path to a pre-rendered route manifest JSON file produced by
145
+ * `dbx-cli-generate-route-manifest`. When set, the runtime reads it once at
146
+ * boot and registers the built-in `url-models` tool, which decodes an app URL
147
+ * into the Firestore models its page renders.
148
+ *
149
+ * Optional. When unset or the file is missing, the runtime skips registering
150
+ * `url-models` and emits a single boot warning when the path was set but
151
+ * unreadable.
152
+ */
153
+ readonly mcpRouteManifestPath?: string;
83
154
  /**
84
155
  * When `true`, the MCP server only advertises tools whose effective read-only classification is `true`.
85
156
  *
@@ -91,6 +162,17 @@ export declare abstract class McpModuleConfig {
91
162
  * ` (read-only)` so the client surface reflects the mode.
92
163
  */
93
164
  readonly readOnly?: boolean;
165
+ /**
166
+ * Controls the auto-injected `reason` parameter added to every advertised tool's input schema.
167
+ *
168
+ * Enabled by default (unset / `true` = defaults on). Pass a {@link McpReasonParameterConfig} object
169
+ * to tune `required`, `maxLength`, `description`, or `parameterName`, or `false` to disable it.
170
+ *
171
+ * When enabled, every advertised tool's `inputSchema` carries a required `reason` string the model
172
+ * fills with a short justification for the call. The value is forwarded to analytics and stripped
173
+ * from the dispatched handler body. See {@link McpReasonParameterConfig}.
174
+ */
175
+ readonly reasonParameter?: McpReasonParameterConfig | boolean;
94
176
  }
95
177
  /**
96
178
  * Signature for the optional role reader the MCP module uses when evaluating
@@ -58,9 +58,16 @@ export interface McpAnalyticsEvent {
58
58
  */
59
59
  readonly readOnly?: Maybe<boolean>;
60
60
  /**
61
- * The raw tool arguments passed to the call.
61
+ * The raw tool arguments passed to the call, with the auto-injected reason parameter already
62
+ * stripped (so it never appears twice — once here and once on {@link reason}).
62
63
  */
63
64
  readonly args?: Maybe<Record<string, unknown>>;
65
+ /**
66
+ * The auto-injected, human-readable reason the caller supplied for this tool call, clamped to the
67
+ * configured max length. `undefined` when the reason parameter is disabled, absent, or the tool
68
+ * declares its own field of the same name. Recorded for analytics/audit only.
69
+ */
70
+ readonly reason?: Maybe<string>;
64
71
  /**
65
72
  * Custom key-value properties. Reserved for future use.
66
73
  */
@@ -1,5 +1,7 @@
1
1
  export * from './analytics';
2
2
  export * from './mcp.manifest';
3
+ export * from './mcp.reason';
4
+ export * from './mcp.route-manifest';
3
5
  export * from './mcp.response-formatter';
4
6
  export * from './mcp.server.factory';
5
7
  export * from './mcp.tool-generator';
@@ -7,4 +9,6 @@ export * from './mcp.visibility';
7
9
  export * from './tools/mcp.tool.model-get';
8
10
  export * from './tools/mcp.tool.model-info';
9
11
  export * from './tools/mcp.tool.model-decode';
12
+ export * from './tools/mcp.tool.enum-info';
13
+ export * from './tools/mcp.tool.url-models';
10
14
  export * from './tools/mcp.tool.batch-execute';
@@ -82,6 +82,28 @@ export interface McpManifestModelEntry {
82
82
  readonly sourceFile: string;
83
83
  };
84
84
  }
85
+ /**
86
+ * One enum value with its persisted literal and the leading JSDoc paragraph.
87
+ *
88
+ * Structural mirror of `@dereekb/dbx-cli`'s `CliModelEnumValue`. Lets the
89
+ * runtime `model-info` / `enum-info` tools decode raw persisted integer/string
90
+ * values (`s: 4`) back to human-readable names without dropping into source.
91
+ */
92
+ export interface McpManifestEnumValue {
93
+ readonly name: string;
94
+ readonly value: number | string;
95
+ readonly description?: string;
96
+ }
97
+ /**
98
+ * One TypeScript enum referenced by some model field's `enumRef`.
99
+ *
100
+ * Structural mirror of `@dereekb/dbx-cli`'s `CliModelEnum`.
101
+ */
102
+ export interface McpManifestEnum {
103
+ readonly name: string;
104
+ readonly values: readonly McpManifestEnumValue[];
105
+ readonly description?: string;
106
+ }
85
107
  /**
86
108
  * One auth claim entry in the pre-rendered MCP manifest JSON.
87
109
  *
@@ -138,6 +160,10 @@ export interface McpManifestAuth {
138
160
  * manifests rendered before model catalog support landed), the runtime skips
139
161
  * registering those tools instead of failing the boot.
140
162
  *
163
+ * The optional `enums` map (keyed by enum name) carries the value→label tables
164
+ * `model-info` inlines and the `enum-info` static tool serves. Absent on legacy
165
+ * manifests; the runtime then skips registering `enum-info`.
166
+ *
141
167
  * The optional `auth` section drives the built-in `whoami` static tool.
142
168
  */
143
169
  export interface McpManifest {
@@ -147,6 +173,9 @@ export interface McpManifest {
147
173
  readonly [key: string]: McpManifestToolEntry | undefined;
148
174
  };
149
175
  readonly models?: readonly McpManifestModelEntry[];
176
+ readonly enums?: {
177
+ readonly [name: string]: McpManifestEnum;
178
+ };
150
179
  readonly auth?: McpManifestAuth;
151
180
  }
152
181
  /**
@@ -0,0 +1,80 @@
1
+ import { type Maybe } from '@dereekb/util';
2
+ import { type McpReasonParameterConfig } from '../mcp.config';
3
+ /**
4
+ * The fully-normalized form of {@link McpReasonParameterConfig}, with every field resolved to a
5
+ * concrete value via {@link resolveMcpReasonParameterConfig}.
6
+ */
7
+ export interface ResolvedMcpReasonParameterConfig {
8
+ readonly enabled: boolean;
9
+ readonly required: boolean;
10
+ readonly maxLength: number;
11
+ readonly description: string;
12
+ readonly parameterName: string;
13
+ }
14
+ /**
15
+ * Normalizes the app-supplied {@link McpModuleConfig.reasonParameter} value (object / boolean /
16
+ * undefined) into a fully-resolved {@link ResolvedMcpReasonParameterConfig} with the package defaults
17
+ * applied.
18
+ *
19
+ * - `undefined` / `true` → defaults, enabled.
20
+ * - `false` → defaults, but `enabled: false`.
21
+ * - object → per-field overrides on top of the defaults (`enabled` defaults to `true`).
22
+ *
23
+ * @param config - The raw config from {@link McpModuleConfig.reasonParameter}.
24
+ * @returns The resolved config with all defaults applied.
25
+ */
26
+ export declare function resolveMcpReasonParameterConfig(config?: McpReasonParameterConfig | boolean): ResolvedMcpReasonParameterConfig;
27
+ /**
28
+ * Returns `true` when the JSON schema already declares a property named `name` under `properties`.
29
+ *
30
+ * Used as the collision guard so a handler that legitimately consumes its own field of the same name
31
+ * is never double-declared (schema injection) or stripped (arg extraction).
32
+ *
33
+ * @param schema - The candidate JSON schema object (may be `undefined`).
34
+ * @param name - The property name to look for.
35
+ * @returns Whether the schema's `properties` already contains `name`.
36
+ */
37
+ export declare function mcpSchemaDeclaresProperty(schema: object | undefined, name: string): boolean;
38
+ /**
39
+ * Returns a NEW JSON schema object with the reason parameter property merged into `properties` (and
40
+ * appended to `required` when configured). Never mutates the input — the static wire schemas are
41
+ * shared module-level constants reused across requests.
42
+ *
43
+ * Self-skips (returns the input schema unchanged) when the config is disabled or the schema already
44
+ * declares the parameter name. Defensive against a non-object / missing-`properties` input.
45
+ *
46
+ * @param inputSchema - The tool's resolved input schema.
47
+ * @param resolved - The resolved reason-parameter config.
48
+ * @returns A new schema carrying the reason property, or the original schema when skipped.
49
+ */
50
+ export declare function applyMcpReasonParameterToSchema(inputSchema: object, resolved: ResolvedMcpReasonParameterConfig): object;
51
+ /**
52
+ * The result of extracting the reason value from a tool call's raw arguments.
53
+ */
54
+ export interface ExtractedMcpReason {
55
+ /**
56
+ * The extracted, clamped reason string, or `undefined` when absent / disabled / handler-owned.
57
+ */
58
+ readonly reason: Maybe<string>;
59
+ /**
60
+ * The arguments to forward to the underlying handler — with the reason key removed when this module
61
+ * owns it, or the original args untouched otherwise.
62
+ */
63
+ readonly args: Record<string, unknown>;
64
+ }
65
+ /**
66
+ * Splits the auto-injected reason value out of a tool call's raw arguments.
67
+ *
68
+ * When enabled and the tool did NOT declare its own field of the same name: shallow-copies the args,
69
+ * deletes the parameter key, and coerces + clamps the value to `maxLength` (the client is not trusted).
70
+ * An empty / absent value yields `reason: undefined`.
71
+ *
72
+ * Otherwise (disabled, or the handler legitimately owns the field) returns the args unchanged so a
73
+ * field the handler actually consumes is never stripped.
74
+ *
75
+ * @param args - The raw `tools/call` arguments.
76
+ * @param resolved - The resolved reason-parameter config.
77
+ * @param toolDeclaresOwnReason - Whether the tool's own input schema already declares the parameter.
78
+ * @returns The extracted reason plus the (possibly stripped) args to dispatch.
79
+ */
80
+ export declare function extractMcpReasonFromArgs(args: Record<string, unknown>, resolved: ResolvedMcpReasonParameterConfig, toolDeclaresOwnReason: boolean): ExtractedMcpReason;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Runtime mirror of the build-time route manifest schema + a ported URL matcher.
3
+ *
4
+ * Structural mirror of `@dereekb/dbx-cli`'s `route-manifest.ts` types and the
5
+ * pure `url-match.ts` matcher, so the firebase-server/mcp runtime can consume
6
+ * the generated `route.manifest.json` without taking a runtime dependency on
7
+ * the build-time CLI package. Both sides bump {@link ROUTE_MANIFEST_VERSION}
8
+ * together when the manifest shape changes.
9
+ */
10
+ /**
11
+ * Version stamp embedded in `route.manifest.json`. Runtime loaders refuse
12
+ * manifests whose `version` does not match. Mirror in `@dereekb/dbx-cli`'s
13
+ * `ROUTE_MANIFEST_VERSION` — bump both together.
14
+ */
15
+ export declare const ROUTE_MANIFEST_VERSION: 1;
16
+ /**
17
+ * Whether a route-model entry resolves to a promoted id, a full key, or a
18
+ * keyless list. Mirror of `@dereekb/dbx-cli`'s `RouteModelKind`.
19
+ */
20
+ export type RouteModelKind = 'id' | 'key' | 'list';
21
+ /**
22
+ * One model an app page renders. Structural mirror of `@dereekb/dbx-cli`'s
23
+ * `RouteManifestModelEntry`.
24
+ */
25
+ export interface RouteManifestModelEntry {
26
+ readonly modelType: string;
27
+ readonly kind: RouteModelKind;
28
+ readonly keyTemplate?: string;
29
+ readonly description?: string;
30
+ readonly from?: string;
31
+ }
32
+ /**
33
+ * One UIRouter state, inheritance pre-flattened into `models`. Structural mirror
34
+ * of `@dereekb/dbx-cli`'s `RouteManifestStateEntry`.
35
+ */
36
+ export interface RouteManifestStateEntry {
37
+ readonly name: string;
38
+ readonly url?: string;
39
+ readonly fullUrl?: string;
40
+ readonly parentName?: string;
41
+ readonly paramKeys: readonly string[];
42
+ readonly urlParamKeys: readonly string[];
43
+ readonly component?: string;
44
+ readonly componentFile?: string;
45
+ readonly abstract?: boolean;
46
+ readonly redirectTo?: string;
47
+ readonly models: readonly RouteManifestModelEntry[];
48
+ }
49
+ /**
50
+ * The full `route.manifest.json` shape. Structural mirror of `@dereekb/dbx-cli`'s
51
+ * `RouteManifest`.
52
+ */
53
+ export interface RouteManifest {
54
+ readonly version: typeof ROUTE_MANIFEST_VERSION;
55
+ readonly generatedAt: string;
56
+ readonly app: {
57
+ readonly name: string;
58
+ readonly baseUrl?: string;
59
+ };
60
+ readonly states: readonly RouteManifestStateEntry[];
61
+ }
62
+ /**
63
+ * A single resolved state match. `via` distinguishes a literal path match from
64
+ * a parameterised one; `params` carries the captured `:param` / `{param}`
65
+ * values (empty for literal matches).
66
+ */
67
+ export interface RouteUrlMatch {
68
+ readonly kind: 'match';
69
+ readonly via: 'literal' | 'param';
70
+ readonly state: RouteManifestStateEntry;
71
+ readonly params: Readonly<Record<string, string>>;
72
+ readonly pathname: string;
73
+ }
74
+ /**
75
+ * More than one state matched at the same tier.
76
+ */
77
+ export interface RouteUrlAmbiguous {
78
+ readonly kind: 'ambiguous';
79
+ readonly states: readonly RouteManifestStateEntry[];
80
+ readonly pathname: string;
81
+ }
82
+ /**
83
+ * No state matched; `candidates` holds the closest scored near-misses (top 5).
84
+ */
85
+ export interface RouteUrlNone {
86
+ readonly kind: 'none';
87
+ readonly candidates: readonly RouteManifestStateEntry[];
88
+ readonly pathname: string;
89
+ }
90
+ export type RouteUrlMatchResult = RouteUrlMatch | RouteUrlAmbiguous | RouteUrlNone;
91
+ /**
92
+ * Input to {@link matchRouteManifestUrl}.
93
+ */
94
+ export interface MatchRouteManifestUrlInput {
95
+ readonly manifest: RouteManifest;
96
+ readonly url: string;
97
+ }
98
+ /**
99
+ * Extracts the normalized pathname from a full URL or a bare path. Strips the
100
+ * scheme/host/port, query string, and hash; collapses an empty path to `/` and
101
+ * trims a single trailing slash.
102
+ *
103
+ * @param url - A full URL (`https://app.example.co/worker/x/timesheets/list`) or bare path.
104
+ * @returns The normalized pathname.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * parseUrlModelsPathname('https://app.hellosubs.co/worker/abc/timesheets/list/'); // => '/worker/abc/timesheets/list'
109
+ * ```
110
+ */
111
+ export declare function parseUrlModelsPathname(url: string): string;
112
+ /**
113
+ * Matches a URL against the manifest's states, preferring a literal match over
114
+ * a parameterised one. A tie at either tier collapses to `ambiguous`; otherwise
115
+ * the closest near-misses are scored and returned in a `none` result.
116
+ *
117
+ * @param input - The manifest and the URL (or pathname) to resolve.
118
+ * @returns A discriminated match / ambiguous / none result.
119
+ */
120
+ export declare function matchRouteManifestUrl(input: MatchRouteManifestUrlInput): RouteUrlMatchResult;
@@ -32,10 +32,14 @@ export declare class McpServerFactoryService {
32
32
  private _cachedStaticTools;
33
33
  private _cachedManifest;
34
34
  private _cachedManifestModels;
35
+ private _cachedManifestEnums;
35
36
  private _cachedManifestAuth;
37
+ private _cachedRouteManifest;
38
+ private _routeManifestLoaded;
36
39
  private _manifestLoaded;
37
40
  private _loggedSkips;
38
41
  private _warnedMissingRoleReader;
42
+ private _resolvedReasonConfig?;
39
43
  private readonly _analyticsService;
40
44
  constructor(mcpConfig: McpModuleConfig, dispatchService: ModelApiCallModelDispatchService, modelApiGetService?: ModelApiGetService | undefined, roleReader?: McpAuthRoleReader | undefined, analyticsService?: McpAnalyticsService, storageService?: FirebaseServerStorageService | undefined);
41
45
  /**
@@ -129,6 +133,17 @@ export declare class McpServerFactoryService {
129
133
  */
130
134
  private _resolveManifest;
131
135
  private _resetManifestCache;
136
+ /**
137
+ * Reads the pre-rendered route manifest JSON once, validates its version, and caches the result
138
+ * for the process lifetime. Drives whether the built-in `url-models` static tool is registered.
139
+ *
140
+ * Missing file or wrong version fall back to "no route manifest" with a single boot warning;
141
+ * the `url-models` tool is then simply not offered.
142
+ *
143
+ * @returns The cached route manifest, or `undefined` when none was loaded.
144
+ */
145
+ private _resolveRouteManifest;
146
+ private _parseRouteManifestFile;
132
147
  private _parseManifestFile;
133
148
  private _applyParsedManifest;
134
149
  /**
@@ -155,12 +170,24 @@ export declare class McpServerFactoryService {
155
170
  private _filterToolsForRequest;
156
171
  private _passesVisibility;
157
172
  private _checkDeclarativeVisibility;
173
+ /**
174
+ * Resolves the app's reason-parameter config once and caches it for the process lifetime.
175
+ *
176
+ * The underlying {@link McpModuleConfig.reasonParameter} value is fixed at boot, so the normalized
177
+ * form is safe to memoize and reuse across every `tools/list` and `tools/call`.
178
+ *
179
+ * @returns The resolved reason-parameter config (defaults applied).
180
+ */
181
+ private _resolveReasonConfig;
158
182
  /**
159
183
  * Resolves the wire-shape `tools/list` entry for a single tool.
160
184
  *
161
185
  * Hot-path short-circuit: tools without a `toolDetailsBuilder` reuse the precomputed,
162
186
  * frozen {@link McpToolDefinition.staticWireEntry} verbatim — zero allocations per
163
- * request for the common case.
187
+ * request for the common case. When the auto-injected reason parameter is enabled (the
188
+ * default), the entry is no longer returned frozen-verbatim: its `inputSchema` is wrapped
189
+ * with the reason property per request (`tools/list` is low-frequency, so the allocation is
190
+ * acceptable). Tools that already declare the parameter name are still reused verbatim.
164
191
  *
165
192
  * Tools that opted in to {@link McpToolDetailsBuilder} get a fresh wire entry built
166
193
  * from the builder's overrides. If the builder throws, the framework falls back to
@@ -1,8 +1,8 @@
1
1
  import { type Maybe } from '@dereekb/util';
2
2
  import { type KnownOnCallFunctionType } from '@dereekb/firebase';
3
- import { type ModelApiDetailsResult, type OnCallModelFunctionApiDetails, type FirebaseServerAuthData, type McpToolDetailsBuilder } from '@dereekb/firebase-server';
3
+ import { type ModelApiDetailsResult, type OnCallModelFunctionApiDetails, type FirebaseServerAuthData, type McpToolDetailsBuilder, type McpVisibilityRule } from '@dereekb/firebase-server';
4
4
  import { type Request } from 'express';
5
- import { type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
5
+ import { type CallToolResult, type ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
6
6
  import { type McpManifestToolEntry } from './mcp.manifest';
7
7
  import { type McpToolFilterMetadata } from './mcp.visibility';
8
8
  /**
@@ -17,6 +17,12 @@ export interface McpToolListEntry {
17
17
  readonly description: string;
18
18
  readonly inputSchema: object;
19
19
  readonly outputSchema?: object;
20
+ /**
21
+ * Standard MCP read/write hints (`readOnlyHint`, `destructiveHint`) advertised to clients so a
22
+ * tool's mutating behaviour is machine-readable. Resolved at boot from the effective read-only
23
+ * classification; see {@link resolveMcpToolAnnotations}.
24
+ */
25
+ readonly annotations?: ToolAnnotations;
20
26
  }
21
27
  /**
22
28
  * A single MCP tool definition generated from one (modelType, callType, specifier) triple.
@@ -54,6 +60,12 @@ export interface McpToolDefinition {
54
60
  * response only includes this when the pinned MCP SDK type allows it.
55
61
  */
56
62
  readonly outputSchema?: object;
63
+ /**
64
+ * Standard MCP read/write hints for this tool, mirrored onto {@link staticWireEntry} and the
65
+ * dynamic wire path so opt-in `toolDetails` handlers keep their annotations. Resolved at boot
66
+ * from the effective read-only classification; see {@link resolveMcpToolAnnotations}.
67
+ */
68
+ readonly annotations?: ToolAnnotations;
57
69
  /**
58
70
  * The original handler-level API details. Carries response formatters, analytics
59
71
  * config, etc. — the controller resolves Tier 1/2/3 response shape from this.
@@ -224,6 +236,7 @@ interface BuildStaticWireEntryInput {
224
236
  readonly description: string;
225
237
  readonly inputSchema?: object;
226
238
  readonly outputSchema?: object;
239
+ readonly annotations?: ToolAnnotations;
227
240
  }
228
241
  /**
229
242
  * Builds the frozen wire-shape entry used by the per-request `tools/list` hot path.
@@ -235,6 +248,56 @@ interface BuildStaticWireEntryInput {
235
248
  * @returns A frozen wire entry safe to share across requests.
236
249
  */
237
250
  export declare function buildStaticWireEntry(input: BuildStaticWireEntryInput): McpToolListEntry;
251
+ /**
252
+ * Description prefix prepended to a mutating tool's description so MCP clients that don't render
253
+ * {@link ToolAnnotations} still surface the write signal in plain text.
254
+ */
255
+ export declare const MCP_WRITE_TOOL_DESCRIPTION_PREFIX = "[WRITE] ";
256
+ /**
257
+ * Prepends {@link MCP_WRITE_TOOL_DESCRIPTION_PREFIX} to a description when its annotations mark the
258
+ * tool as a write (`readOnlyHint === false`). Read-only tools are returned unchanged.
259
+ *
260
+ * @param description - The base tool description.
261
+ * @param annotations - The resolved MCP annotations for the tool.
262
+ * @returns The description, prefixed with the write marker when the tool mutates.
263
+ */
264
+ export declare function applyWriteMarker(description: string, annotations: ToolAnnotations): string;
265
+ /**
266
+ * Input for {@link buildStaticToolDefinition}.
267
+ */
268
+ export interface BuildStaticToolDefinitionInput {
269
+ readonly name: string;
270
+ readonly description: string;
271
+ readonly inputSchema: object;
272
+ readonly outputSchema?: object;
273
+ readonly dispatch: McpToolDispatchTarget;
274
+ readonly staticHandler: McpStaticToolHandler;
275
+ /**
276
+ * Read-only classification for this built-in tool. Drives both the advertised MCP annotations
277
+ * (`readOnlyHint` / `destructiveHint`) and the per-request module-level `readOnly` filter.
278
+ */
279
+ readonly effectiveReadOnly: boolean;
280
+ /**
281
+ * Declarative visibility rule checked per request. Defaults to `{}` (always visible) when omitted.
282
+ */
283
+ readonly rule?: McpVisibilityRule;
284
+ }
285
+ /**
286
+ * Assembles a statically-registered {@link McpToolDefinition} with consistent MCP annotations.
287
+ *
288
+ * Built-in tools (e.g. `whoami`, `model-get`, `batch-execute`) don't flow through
289
+ * {@link buildToolFromCandidate}, so this is their analogous single source of truth: it derives the
290
+ * read/write annotations from {@link BuildStaticToolDefinitionInput.effectiveReadOnly} via
291
+ * {@link resolveMcpToolAnnotations}, applies the {@link MCP_WRITE_TOOL_DESCRIPTION_PREFIX} write
292
+ * marker to the description, then mirrors the annotations onto both the definition's `annotations`
293
+ * field and the frozen {@link McpToolDefinition.staticWireEntry}. Centralizing this guarantees no
294
+ * built-in tool reaches `tools/list` un-annotated (which would surface as an uncategorized "other"
295
+ * tool in clients that bucket by `readOnlyHint`).
296
+ *
297
+ * @param input - The tool identity, schemas, dispatch target, static handler, and read-only classification.
298
+ * @returns A statically-registered {@link McpToolDefinition} to append to the MCP server factory's tool registry.
299
+ */
300
+ export declare function buildStaticToolDefinition(input: BuildStaticToolDefinitionInput): McpToolDefinition;
238
301
  /**
239
302
  * The default specifier key used when a handler is not behind a specifier router.
240
303
  */
@@ -1,6 +1,7 @@
1
1
  import { type Maybe } from '@dereekb/util';
2
2
  import { type CallModelOidcScope } from '@dereekb/firebase';
3
3
  import { type McpToolVisibility, type McpVisibilityContext, type McpVisibilityRule } from '@dereekb/firebase-server';
4
+ import { type ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
4
5
  /**
5
6
  * Normalized classification of a {@link McpToolVisibility} value computed at boot.
6
7
  *
@@ -87,6 +88,22 @@ export declare function classifyVisibility(visibility?: McpToolVisibility): Clas
87
88
  * @returns The effective read-only flag, or `undefined` when neither source resolves a value.
88
89
  */
89
90
  export declare function resolveEffectiveReadOnly(explicitReadOnly: Maybe<boolean>, callType: string): boolean | undefined;
91
+ /**
92
+ * Maps an effective read-only classification to the standard MCP {@link ToolAnnotations} hints
93
+ * advertised on `tools/list`.
94
+ *
95
+ * A definitively read-only tool advertises `{ readOnlyHint: true }`. Everything else — a known
96
+ * write (`false`) or an unclassified verb (`undefined`, e.g. `invoke`/custom) — fails safe to
97
+ * `{ readOnlyHint: false, destructiveHint: true }` so a client never mistakes an ambiguous tool
98
+ * for a safe read. `idempotentHint` / `openWorldHint` are intentionally left unset.
99
+ *
100
+ * Co-located with {@link resolveEffectiveReadOnly} / {@link READ_ONLY_BY_CALL_TYPE} so the
101
+ * read/write classification rules stay in one file.
102
+ *
103
+ * @param effectiveReadOnly - The resolved read-only classification from {@link resolveEffectiveReadOnly}.
104
+ * @returns The MCP annotations describing the tool's read/write behaviour.
105
+ */
106
+ export declare function resolveMcpToolAnnotations(effectiveReadOnly: boolean | undefined): ToolAnnotations;
90
107
  /**
91
108
  * Resolves the OIDC scope required to invoke a given call type, or `undefined` for
92
109
  * non-CRUD calls. Thin re-export so the tool generator doesn't need to reach into