@frihet/mcp-server 1.16.6 → 2.0.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.
Files changed (108) hide show
  1. package/README.md +168 -82
  2. package/dist/agent-onboarding.d.ts +98 -0
  3. package/dist/agent-onboarding.js +275 -0
  4. package/dist/api-origin.d.ts +9 -0
  5. package/dist/api-origin.js +41 -0
  6. package/dist/canary/mcp-harness.d.ts +77 -0
  7. package/dist/canary/mcp-harness.js +552 -0
  8. package/dist/capability-truth.d.ts +29 -0
  9. package/dist/capability-truth.js +262 -0
  10. package/dist/claude-candidate-contract.d.ts +482 -0
  11. package/dist/claude-candidate-contract.js +87 -0
  12. package/dist/claude-output-privacy.d.ts +17 -0
  13. package/dist/claude-output-privacy.js +240 -0
  14. package/dist/claude-profile.d.ts +18 -0
  15. package/dist/claude-profile.js +260 -0
  16. package/dist/client-interface.d.ts +47 -33
  17. package/dist/client.d.ts +172 -40
  18. package/dist/client.js +708 -165
  19. package/dist/demo-client.d.ts +41 -32
  20. package/dist/demo-client.js +202 -31
  21. package/dist/demo-fixtures.d.ts +3 -3
  22. package/dist/demo-fixtures.js +7 -7
  23. package/dist/deposit-contract.d.ts +29 -0
  24. package/dist/deposit-contract.js +76 -0
  25. package/dist/fetch-no-redirect.d.ts +3 -0
  26. package/dist/fetch-no-redirect.js +16 -0
  27. package/dist/fiscal-aliases.d.ts +44 -0
  28. package/dist/fiscal-aliases.js +62 -0
  29. package/dist/fiscal-period.d.ts +26 -0
  30. package/dist/fiscal-period.js +31 -0
  31. package/dist/google-profile.d.ts +19 -0
  32. package/dist/google-profile.js +122 -0
  33. package/dist/index.js +68 -59
  34. package/dist/logger.d.ts +2 -2
  35. package/dist/logger.js +140 -8
  36. package/dist/muse-profile.d.ts +19 -0
  37. package/dist/muse-profile.js +122 -0
  38. package/dist/observability.d.ts +18 -29
  39. package/dist/observability.js +194 -106
  40. package/dist/openai-profile.d.ts +26 -22
  41. package/dist/openai-profile.js +1193 -157
  42. package/dist/openai-review-contract.d.ts +15 -6
  43. package/dist/openai-review-contract.js +714 -41
  44. package/dist/openai-review-oauth.d.ts +149 -2
  45. package/dist/openai-review-oauth.js +220 -6
  46. package/dist/prompts/register-all.d.ts +2 -0
  47. package/dist/prompts/register-all.js +34 -78
  48. package/dist/public-capability-contract.d.ts +39 -0
  49. package/dist/public-capability-contract.js +186 -0
  50. package/dist/redaction.d.ts +15 -0
  51. package/dist/redaction.js +83 -0
  52. package/dist/resources/register-all.js +154 -64
  53. package/dist/retry-after.d.ts +19 -0
  54. package/dist/retry-after.js +84 -0
  55. package/dist/server-composition.d.ts +19 -0
  56. package/dist/server-composition.js +74 -0
  57. package/dist/tool-exposure.d.ts +31 -27
  58. package/dist/tool-exposure.js +211 -87
  59. package/dist/tools/accountingClose.d.ts +14 -5
  60. package/dist/tools/accountingClose.js +41 -23
  61. package/dist/tools/audit_gl.js +8 -8
  62. package/dist/tools/backend-availability.d.ts +8 -29
  63. package/dist/tools/backend-availability.js +31 -30
  64. package/dist/tools/bank_rules.js +66 -15
  65. package/dist/tools/banking.js +5 -13
  66. package/dist/tools/clients.js +261 -15
  67. package/dist/tools/crm.js +22 -21
  68. package/dist/tools/deposits.js +114 -85
  69. package/dist/tools/einvoice.d.ts +16 -2
  70. package/dist/tools/einvoice.js +59 -12
  71. package/dist/tools/expenses.d.ts +16 -0
  72. package/dist/tools/expenses.js +47 -27
  73. package/dist/tools/fiscal.d.ts +3 -4
  74. package/dist/tools/fiscal.js +93 -60
  75. package/dist/tools/gestoria.js +15 -7
  76. package/dist/tools/hr.d.ts +2 -3
  77. package/dist/tools/hr.js +49 -20
  78. package/dist/tools/igic.d.ts +9 -8
  79. package/dist/tools/igic.js +34 -64
  80. package/dist/tools/impuesto_sociedades.d.ts +4 -5
  81. package/dist/tools/impuesto_sociedades.js +16 -37
  82. package/dist/tools/intelligence.js +166 -12
  83. package/dist/tools/invoices.js +224 -54
  84. package/dist/tools/kitchen.js +19 -9
  85. package/dist/tools/payroll.d.ts +5 -8
  86. package/dist/tools/payroll.js +19 -32
  87. package/dist/tools/permissions.d.ts +4 -7
  88. package/dist/tools/permissions.js +11 -14
  89. package/dist/tools/products.js +6 -6
  90. package/dist/tools/quotes.js +172 -26
  91. package/dist/tools/recurring.js +26 -25
  92. package/dist/tools/register-all.d.ts +2 -2
  93. package/dist/tools/register-all.js +9 -6
  94. package/dist/tools/search.d.ts +19 -0
  95. package/dist/tools/search.js +84 -0
  96. package/dist/tools/shared.d.ts +1445 -109
  97. package/dist/tools/shared.js +478 -102
  98. package/dist/tools/team.js +24 -28
  99. package/dist/tools/time.js +4 -12
  100. package/dist/tools/vendors.js +249 -15
  101. package/dist/tools/webhooks.js +35 -30
  102. package/dist/types.d.ts +31 -5
  103. package/dist/workflows/daily-operational-brief.d.ts +163 -0
  104. package/dist/workflows/daily-operational-brief.js +881 -0
  105. package/docs/agent-onboarding.json +391 -0
  106. package/package.json +49 -12
  107. package/dist/tools/onboarding.d.ts +0 -19
  108. package/dist/tools/onboarding.js +0 -64
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Tool-exposure profile for the Frihet MCP server — progressive disclosure.
3
3
  *
4
- * Activated by FRIHET_TOOL_MODE=grouped (env var or Worker binding).
5
- * Default (unset, or FRIHET_TOOL_MODE=full) leaves the server BYTE-IDENTICAL
6
- * to today: all 157 tools registered with their full descriptions/schemas.
4
+ * Activated by FRIHET_TOOL_MODE=grouped (env var or Worker binding). Default
5
+ * full mode keeps full descriptions/schemas and does not add discovery names;
6
+ * the outer public composition may still attach capability truth metadata.
7
7
  *
8
8
  * ── Why ──────────────────────────────────────────────────────────────────
9
- * Context rot is the 2026 problem: a flat list of 157 tool descriptions,
9
+ * Context rot is the 2026 problem: a flat list of 158 tool descriptions,
10
10
  * each a multi-paragraph bilingual blob, eats the agent's context window and
11
11
  * degrades tool selection before any work begins. Leaders cut flat lists.
12
12
  *
@@ -23,26 +23,26 @@
23
23
  * the server.
24
24
  * 2. Still registers every tool so it stays INVOCABLE (nothing breaks; tool
25
25
  * logic, names and behavior are untouched) — but COLLAPSES its registered
26
- * description to a single terse "[group] summary — full schema via
27
- * describe_tool('name')" line. That is the context saving.
26
+ * description to a single terse "[group] summary — full description and
27
+ * input fields via describe_tool('name')" line. That is the context saving.
28
28
  * 3. Adds three lightweight META-TOOLS as the entry point for discovery:
29
- * • list_tool_groups() — the 9-domain map with per-group counts
29
+ * • list_tool_groups() — the 11-domain map with per-group counts
30
30
  * • search_tools(query) — fuzzy match → matching tool summaries
31
31
  * • describe_tool(name) — full original description + input fields
32
32
  *
33
- * The agent loads ~3 meta-tool descriptions + 157 terse one-liners instead of
34
- * 157 full bilingual blobs, then pulls full depth only for the handful of
33
+ * The agent loads ~3 meta-tool descriptions + 158 terse one-liners instead of
34
+ * 158 full bilingual blobs, then pulls full depth only for the handful of
35
35
  * tools it actually needs. Progressive disclosure, zero behavior change.
36
36
  *
37
37
  * IMPORTANT: this is purely an EXPOSURE layer. It does NOT live in
38
- * src/tools/*.ts, so the audited tool count stays 157 (+ meta). The meta-tools
38
+ * src/tools/*.ts, so the audited tool count stays 158 (+ meta). The meta-tools
39
39
  * are added only in grouped mode and are NOT counted as ERP tools.
40
40
  *
41
41
  * @see ./openai-profile.ts — the sibling interceptor this mirrors.
42
42
  */
43
43
  /** Stable domain identifiers used by the group router. */
44
44
  export type ToolGroupId = "invoicing" | "expenses" | "fiscal" | "banking" | "crm" | "hr" | "stay" | "pos" | "intelligence" | "catalog" | "platform";
45
- interface GroupMeta {
45
+ export interface GroupMeta {
46
46
  /** Human label, bilingual. */
47
47
  label: string;
48
48
  /** One-line domain blurb shown in list_tool_groups. */
@@ -64,7 +64,7 @@ export declare const GROUPS: Record<ToolGroupId, GroupMeta>;
64
64
  export declare const FILE_TO_GROUP: Record<string, ToolGroupId>;
65
65
  /**
66
66
  * Assign a group by tool NAME. The name-based mapping reproduces the
67
- * source-file grouping exactly for all 157 current tools (verified), with the
67
+ * source-file grouping exactly for every current tool (verified), with the
68
68
  * eight cross-file cases pinned via NAME_OVERRIDES. Driven off the name (not a
69
69
  * hand-kept list) so a future tool lands somewhere sensible automatically.
70
70
  *
@@ -81,6 +81,10 @@ interface CatalogEntry {
81
81
  description: string;
82
82
  /** Whether the tool mutates state (from annotations.readOnlyHint). */
83
83
  readOnly: boolean;
84
+ /** Full MCP action annotations, exposed only on the full capability profile. */
85
+ annotations?: Record<string, boolean>;
86
+ /** Conservative runtime capability fact, exposed only on the full profile. */
87
+ capability?: Record<string, unknown>;
84
88
  /** Input field names, for quick schema shape without dumping zod. */
85
89
  inputFields: string[];
86
90
  }
@@ -118,25 +122,25 @@ export declare function resolveToolMode(env?: Record<string, string | undefined>
118
122
  * @param server The McpServer (typed loosely to match the openai-profile shim).
119
123
  * @param options.allowlist When provided, ONLY tools whose name is in this set
120
124
  * are catalogued + collapsed; any other tool is passed through untouched.
121
- * This is the OpenAI-composition path: it pins the grouped catalog (and the
122
- * tools search_tools / describe_tool / list_tool_groups surface) to EXACTLY
123
- * the reviewed allow-list, so progressive disclosure can never reveal or
124
- * describe a tool outside the 53-tool ChatGPT-reviewed surface. Omit (default)
125
- * for the open mcp.frihet.io surface, which catalogs every registered tool.
126
- *
127
- * COMPOSITION ORDERING (openai-mcp): apply this profile FIRST so its
128
- * originalRegisterTool is the REAL server.registerTool — the three meta-tools
129
- * are then registered straight onto the real server and BYPASS the OpenAI
130
- * allow-list gate entirely (so they always materialise without polluting the
131
- * OpenAI includeTools set / its advertised 53-tool count). Apply
132
- * applyOpenAIProfile() SECOND (outermost) so a business-tool registration is
133
- * first redacted + annotated + openWorldHint-justified by OpenAI, and only
134
- * THEN collapsed here — making the terse line the final description while the
135
- * OpenAI redaction wrapper around the handler survives intact.
125
+ * This supports a deliberately restricted grouped surface without allowing
126
+ * discovery to reveal or describe tools outside that surface. Omit (default)
127
+ * for mcp.frihet.io, which catalogs every registered tool. The reviewed
128
+ * OpenAI host does not use grouped exposure: it publishes the exact 33-tool
129
+ * allow-list with complete descriptions and no discovery meta-tools.
136
130
  * @returns a handle exposing the live catalog (useful for tests/logging).
137
131
  */
138
132
  export declare function applyToolExposureProfile(server: any, options?: {
139
133
  allowlist?: ReadonlySet<string>;
134
+ groupMetadata?: Partial<Record<ToolGroupId, GroupMeta>>;
135
+ describeToolExample?: string;
136
+ capabilityTruth?: {
137
+ metaKey: string;
138
+ localDiscovery: (name: string) => object;
139
+ };
140
+ securitySchemes?: ReadonlyArray<{
141
+ type: "oauth2";
142
+ scopes: readonly string[];
143
+ }>;
140
144
  }): ToolExposureHandle;
141
145
  /** Number of meta-tools added in grouped mode (for logging). */
142
146
  export declare const GROUPED_META_TOOL_COUNT: number;
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Tool-exposure profile for the Frihet MCP server — progressive disclosure.
3
3
  *
4
- * Activated by FRIHET_TOOL_MODE=grouped (env var or Worker binding).
5
- * Default (unset, or FRIHET_TOOL_MODE=full) leaves the server BYTE-IDENTICAL
6
- * to today: all 157 tools registered with their full descriptions/schemas.
4
+ * Activated by FRIHET_TOOL_MODE=grouped (env var or Worker binding). Default
5
+ * full mode keeps full descriptions/schemas and does not add discovery names;
6
+ * the outer public composition may still attach capability truth metadata.
7
7
  *
8
8
  * ── Why ──────────────────────────────────────────────────────────────────
9
- * Context rot is the 2026 problem: a flat list of 157 tool descriptions,
9
+ * Context rot is the 2026 problem: a flat list of 158 tool descriptions,
10
10
  * each a multi-paragraph bilingual blob, eats the agent's context window and
11
11
  * degrades tool selection before any work begins. Leaders cut flat lists.
12
12
  *
@@ -23,19 +23,19 @@
23
23
  * the server.
24
24
  * 2. Still registers every tool so it stays INVOCABLE (nothing breaks; tool
25
25
  * logic, names and behavior are untouched) — but COLLAPSES its registered
26
- * description to a single terse "[group] summary — full schema via
27
- * describe_tool('name')" line. That is the context saving.
26
+ * description to a single terse "[group] summary — full description and
27
+ * input fields via describe_tool('name')" line. That is the context saving.
28
28
  * 3. Adds three lightweight META-TOOLS as the entry point for discovery:
29
- * • list_tool_groups() — the 9-domain map with per-group counts
29
+ * • list_tool_groups() — the 11-domain map with per-group counts
30
30
  * • search_tools(query) — fuzzy match → matching tool summaries
31
31
  * • describe_tool(name) — full original description + input fields
32
32
  *
33
- * The agent loads ~3 meta-tool descriptions + 157 terse one-liners instead of
34
- * 157 full bilingual blobs, then pulls full depth only for the handful of
33
+ * The agent loads ~3 meta-tool descriptions + 158 terse one-liners instead of
34
+ * 158 full bilingual blobs, then pulls full depth only for the handful of
35
35
  * tools it actually needs. Progressive disclosure, zero behavior change.
36
36
  *
37
37
  * IMPORTANT: this is purely an EXPOSURE layer. It does NOT live in
38
- * src/tools/*.ts, so the audited tool count stays 157 (+ meta). The meta-tools
38
+ * src/tools/*.ts, so the audited tool count stays 158 (+ meta). The meta-tools
39
39
  * are added only in grouped mode and are NOT counted as ERP tools.
40
40
  *
41
41
  * @see ./openai-profile.ts — the sibling interceptor this mirrors.
@@ -53,12 +53,17 @@ export const GROUPS = {
53
53
  },
54
54
  expenses: {
55
55
  label: "Expenses & vendors / Gastos y proveedores",
56
- blurb: "Expenses (with OCR) and vendor/supplier records.",
56
+ // C38: this used to read "Expenses (with OCR)". No OCR tool exists — the
57
+ // expenses family is list/get/create/update/delete plus vendor CRUD, and
58
+ // create_expense takes no file, image or URL. Do NOT re-add a capability
59
+ // token here that search_tools cannot resolve to a real tool.
60
+ blurb: "Expenses and vendor/supplier records.",
57
61
  },
58
62
  fiscal: {
59
63
  label: "Fiscal & compliance / Fiscal y cumplimiento",
60
- blurb: "Spanish/EU fiscal depth served on demand: Modelo 303/130/390/180/347/200/202/415/425/418, " +
61
- "VeriFactu, TicketBAI, Facturae/FACe/KSeF e-invoicing, IGIC/AIEM, GL audit, period close, VIES.",
64
+ blurb: "Spanish/EU fiscal depth served on demand: Modelo 303/130/390/347, VeriFactu, TicketBAI, " +
65
+ "Facturae/FACe e-invoicing, GL audit, period close, VIES " +
66
+ "(Modelo 180/200/202/415/418/425, AIEM and KSeF registered but not deployed).",
62
67
  },
63
68
  banking: {
64
69
  label: "Banking / Banca",
@@ -129,13 +134,14 @@ export const FILE_TO_GROUP = {
129
134
  kitchen: "pos",
130
135
  intelligence: "intelligence",
131
136
  gestoria: "intelligence",
137
+ search: "intelligence",
132
138
  products: "catalog",
133
139
  webhooks: "platform",
134
140
  portal_domain: "platform",
135
141
  };
136
142
  /**
137
143
  * Per-tool overrides where the source FILE places a tool in a different group
138
- * than a naive name match would (verified against the 157 registration sites).
144
+ * than a naive name match would (verified against the 158 registration sites).
139
145
  * These eight names live in a file whose domain differs from their name prefix
140
146
  * (e.g. e-invoicing tools say "invoice" but belong to fiscal/compliance).
141
147
  */
@@ -155,7 +161,7 @@ const NAME_OVERRIDES = {
155
161
  };
156
162
  /**
157
163
  * Assign a group by tool NAME. The name-based mapping reproduces the
158
- * source-file grouping exactly for all 157 current tools (verified), with the
164
+ * source-file grouping exactly for every current tool (verified), with the
159
165
  * eight cross-file cases pinned via NAME_OVERRIDES. Driven off the name (not a
160
166
  * hand-kept list) so a future tool lands somewhere sensible automatically.
161
167
  *
@@ -184,7 +190,7 @@ export function groupForTool(name) {
184
190
  return "pos";
185
191
  if (/(kitchen|menu_item)/.test(n))
186
192
  return "pos";
187
- if (/(business_context|monthly_summary|gestoria)/.test(n))
193
+ if (/(business_context|monthly_summary|gestoria|global_search)/.test(n))
188
194
  return "intelligence";
189
195
  if (/(product)/.test(n))
190
196
  return "catalog";
@@ -202,7 +208,44 @@ function firstSentence(desc) {
202
208
  const match = englishHalf.match(/^(.*?[.!?])(\s|$)/);
203
209
  const sentence = (match ? match[1] : englishHalf).trim();
204
210
  // Hard cap so a runaway description can't reintroduce context bloat.
205
- return sentence.length > 160 ? sentence.slice(0, 157).trimEnd() + "…" : sentence;
211
+ return sentence.length > 160 ? sentence.slice(0, 158).trimEnd() + "…" : sentence;
212
+ }
213
+ function inputSchemaFieldNames(schema) {
214
+ if (schema instanceof z.ZodObject) {
215
+ return Object.keys(schema.shape);
216
+ }
217
+ if (schema !== null && typeof schema === "object" && !Array.isArray(schema)) {
218
+ return Object.keys(schema);
219
+ }
220
+ return [];
221
+ }
222
+ /**
223
+ * Does this tool declare `confirm` as a REQUIRED input?
224
+ *
225
+ * Handles both a raw registration shape and a strict ZodObject. The latter is
226
+ * used by create_expense; looking only at `schema["confirm"]` made its required
227
+ * confirmation disappear from the collapsed tools/list description.
228
+ *
229
+ * ── Why this exists ──────────────────────────────────────────────────
230
+ * `firstSentence` keeps only the opening sentence, so a confirm requirement
231
+ * stated in sentence two vanished from every collapsed listing: all 12
232
+ * confirm-gated tools shipped a description that never mentioned the input the
233
+ * SDK rejects the call for. The agent then reads "Delete an invoice by its ID",
234
+ * calls `{id}`, and gets an input-validation error with no prose basis to
235
+ * recover. Deriving the marker here fixes every present AND future guarded tool
236
+ * at once — the alternative (editing 12 first sentences by hand) is the same
237
+ * enumerate-instead-of-derive mistake GAP-04 was raised for.
238
+ */
239
+ function requiresConfirm(inputSchema) {
240
+ const shape = inputSchema instanceof z.ZodObject
241
+ ? inputSchema.shape
242
+ : inputSchema;
243
+ if (!shape || typeof shape !== "object")
244
+ return false;
245
+ const field = shape["confirm"];
246
+ if (typeof field?.safeParse !== "function")
247
+ return false;
248
+ return field.safeParse(undefined).success === false;
206
249
  }
207
250
  /**
208
251
  * Read process.env without referencing the `process` global type — keeps this
@@ -242,21 +285,11 @@ export function resolveToolMode(env = defaultEnv()) {
242
285
  * @param server The McpServer (typed loosely to match the openai-profile shim).
243
286
  * @param options.allowlist When provided, ONLY tools whose name is in this set
244
287
  * are catalogued + collapsed; any other tool is passed through untouched.
245
- * This is the OpenAI-composition path: it pins the grouped catalog (and the
246
- * tools search_tools / describe_tool / list_tool_groups surface) to EXACTLY
247
- * the reviewed allow-list, so progressive disclosure can never reveal or
248
- * describe a tool outside the 53-tool ChatGPT-reviewed surface. Omit (default)
249
- * for the open mcp.frihet.io surface, which catalogs every registered tool.
250
- *
251
- * COMPOSITION ORDERING (openai-mcp): apply this profile FIRST so its
252
- * originalRegisterTool is the REAL server.registerTool — the three meta-tools
253
- * are then registered straight onto the real server and BYPASS the OpenAI
254
- * allow-list gate entirely (so they always materialise without polluting the
255
- * OpenAI includeTools set / its advertised 53-tool count). Apply
256
- * applyOpenAIProfile() SECOND (outermost) so a business-tool registration is
257
- * first redacted + annotated + openWorldHint-justified by OpenAI, and only
258
- * THEN collapsed here — making the terse line the final description while the
259
- * OpenAI redaction wrapper around the handler survives intact.
288
+ * This supports a deliberately restricted grouped surface without allowing
289
+ * discovery to reveal or describe tools outside that surface. Omit (default)
290
+ * for mcp.frihet.io, which catalogs every registered tool. The reviewed
291
+ * OpenAI host does not use grouped exposure: it publishes the exact 33-tool
292
+ * allow-list with complete descriptions and no discovery meta-tools.
260
293
  * @returns a handle exposing the live catalog (useful for tests/logging).
261
294
  */
262
295
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -267,6 +300,13 @@ server, options) {
267
300
  const groups = new Map();
268
301
  const handle = { catalog, groups };
269
302
  const allowlist = options?.allowlist;
303
+ const visibleGroupIds = allowlist
304
+ ? TOOL_GROUP_IDS.filter((group) => [...allowlist].some((name) => groupForTool(name) === group))
305
+ : [...TOOL_GROUP_IDS];
306
+ const groupMetadata = Object.fromEntries(TOOL_GROUP_IDS.map((id) => [
307
+ id,
308
+ options?.groupMetadata?.[id] ?? GROUPS[id],
309
+ ]));
270
310
  const originalRegisterTool = server.registerTool.bind(server);
271
311
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
272
312
  server.registerTool = (name, config, handler) => {
@@ -286,10 +326,12 @@ server, options) {
286
326
  }
287
327
  const group = groupForTool(name);
288
328
  const fullDescription = typeof config?.description === "string" ? config.description : "";
289
- const inputFields = config?.inputSchema
290
- ? Object.keys(config.inputSchema)
291
- : [];
329
+ const inputFields = inputSchemaFieldNames(config?.inputSchema);
292
330
  const readOnly = config?.annotations?.readOnlyHint === true;
331
+ const annotations = config?.annotations;
332
+ const capability = options?.capabilityTruth
333
+ ? config?._meta?.[options.capabilityTruth.metaKey]
334
+ : undefined;
293
335
  const entry = {
294
336
  name,
295
337
  group,
@@ -297,6 +339,7 @@ server, options) {
297
339
  summary: firstSentence(fullDescription),
298
340
  description: fullDescription,
299
341
  readOnly,
342
+ ...(options?.capabilityTruth ? { annotations, capability } : {}),
300
343
  inputFields,
301
344
  };
302
345
  catalog.set(name, entry);
@@ -313,27 +356,41 @@ server, options) {
313
356
  // a per-tool open-world justification on EVERY reviewed tool). When composed
314
357
  // with the OpenAI profile, that profile runs first (outermost) and has
315
358
  // already set the correct annotations.openWorldHint (e.g. true for
316
- // send_invoice / send_quote / create_webhook / update_webhook). We re-derive
359
+ // send_invoice). We re-derive
317
360
  // the one-line rationale here from the (now-correct) annotation so the terse
318
361
  // collapsed description carries it — instead of letting the OpenAI-injected
319
362
  // rationale be lost when we overwrite config.description. Only appended in
320
363
  // allow-list (composition) mode; the open mcp.frihet.io surface keeps its
321
364
  // pure terse line (no OpenAI review constraint there).
365
+ // A required `confirm` is the single most consequential thing an agent can
366
+ // miss: omit it and the call dies on schema validation with no prose basis
367
+ // to recover. firstSentence() keeps only the first English sentence, so a
368
+ // "Requires confirm=true." written into sentence 2 of a tool description is
369
+ // invisible on the surface the agent actually loads. Derive the marker from
370
+ // the SCHEMA instead of trusting prose — it then covers every confirm-gated
371
+ // tool, including ones added after this line was written.
372
+ //
373
+ // In OpenAI mode this is deliberately load-bearing: the reviewed schema
374
+ // keeps `confirm` visible and required, so the collapsed tools/list line
375
+ // tells the agent to obtain explicit authorization before invoking it.
322
376
  let collapsed = `[${group}] ${entry.summary} ` +
323
- `— full schema via describe_tool('${name}').`;
377
+ (requiresConfirm(config?.inputSchema) ? "Requires confirm=true. " : "") +
378
+ `— full description and input fields via describe_tool('${name}').`;
324
379
  if (allowlist) {
325
380
  const ow = config?.annotations?.openWorldHint;
326
- collapsed +=
327
- ow === true
328
- ? " [openWorldHint: true — contacts an entity outside Frihet (an email recipient or an external webhook URL).]"
329
- : " [openWorldHint: false — operates only against the Frihet API (api.frihet.io); no third-party/external calls.]";
381
+ const explicitRationale = fullDescription.match(/\[openWorldHint:\s*(?:true|false)\s+—[^\]]+\]/u)?.[0];
382
+ collapsed += explicitRationale
383
+ ? ` ${explicitRationale}`
384
+ : ow === true
385
+ ? " [openWorldHint: true — may interact with an external endpoint disclosed in describe_tool.]"
386
+ : " [openWorldHint: false — has no user-directed external effect; it only reads or changes the authenticated Frihet workspace.]";
330
387
  }
331
388
  config.description = collapsed;
332
389
  return originalRegisterTool(name, config, handler);
333
390
  };
334
391
  // Register the meta-tools eagerly. Their handlers close over `catalog`/`groups`
335
392
  // which finish populating as the real tools register right after this call.
336
- registerMetaTools(originalRegisterTool, handle);
393
+ registerMetaTools(originalRegisterTool, handle, visibleGroupIds, groupMetadata, options?.capabilityTruth, options?.securitySchemes, options?.describeToolExample);
337
394
  return handle;
338
395
  }
339
396
  /* ------------------------------------------------------------------ */
@@ -363,29 +420,111 @@ function textResult(payload) {
363
420
  */
364
421
  function registerMetaTools(
365
422
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
366
- originalRegisterTool, handle) {
423
+ originalRegisterTool, handle, visibleGroupIds, groupMetadata, capabilityTruth, securitySchemes, describeToolExample = "create_invoice") {
367
424
  const { catalog, groups } = handle;
425
+ const oauthConfig = securitySchemes
426
+ ? {
427
+ securitySchemes: securitySchemes.map((scheme) => ({
428
+ type: scheme.type,
429
+ scopes: [...scheme.scopes],
430
+ })),
431
+ }
432
+ : {};
433
+ const metaConfig = (name) => {
434
+ const metadata = {};
435
+ if (securitySchemes) {
436
+ metadata.securitySchemes = securitySchemes.map((scheme) => ({
437
+ type: scheme.type,
438
+ scopes: [...scheme.scopes],
439
+ }));
440
+ }
441
+ if (capabilityTruth) {
442
+ metadata[capabilityTruth.metaKey] = capabilityTruth.localDiscovery(name);
443
+ }
444
+ return Object.keys(metadata).length > 0 ? { _meta: metadata } : {};
445
+ };
446
+ const searchToolsInputShape = {
447
+ query: z
448
+ .string()
449
+ .optional()
450
+ .describe("Free-text query — matches tool name, title, summary and group. " +
451
+ "Omit to browse a whole group. / Texto libre; omitir para listar un grupo entero."),
452
+ group: z
453
+ .enum(visibleGroupIds)
454
+ .optional()
455
+ .describe("Restrict results to one tool domain / Filtrar por dominio."),
456
+ limit: z
457
+ .number()
458
+ .int()
459
+ .positive()
460
+ .optional()
461
+ .describe("Max results (default 25) / Máximo de resultados (por defecto 25)."),
462
+ };
463
+ const describeToolInputShape = {
464
+ name: z
465
+ .string()
466
+ .describe(`Exact tool name as returned by search_tools (e.g. '${describeToolExample}') / ` +
467
+ "Nombre exacto de la herramienta devuelto por search_tools."),
468
+ };
469
+ const listGroupsOutput = securitySchemes
470
+ ? z.object({
471
+ groups: z.array(z.object({
472
+ group: z.enum(visibleGroupIds),
473
+ label: z.string(),
474
+ blurb: z.string(),
475
+ toolCount: z.number().int().nonnegative(),
476
+ })),
477
+ totalGroups: z.number().int().nonnegative(),
478
+ totalTools: z.number().int().nonnegative(),
479
+ next: z.string(),
480
+ })
481
+ : z.object({}).passthrough();
482
+ const searchToolsOutput = securitySchemes
483
+ ? z.object({
484
+ query: z.string().nullable(),
485
+ group: z.enum(visibleGroupIds).nullable(),
486
+ count: z.number().int().nonnegative(),
487
+ tools: z.array(z.object({
488
+ name: z.string(),
489
+ group: z.enum(visibleGroupIds),
490
+ title: z.string(),
491
+ summary: z.string(),
492
+ readOnly: z.boolean(),
493
+ inputFields: z.array(z.string()),
494
+ score: z.number().int().nonnegative(),
495
+ })),
496
+ next: z.string(),
497
+ })
498
+ : z.object({}).passthrough();
499
+ const describeToolOutput = securitySchemes
500
+ ? z.object({
501
+ name: z.string(),
502
+ group: z.enum(visibleGroupIds),
503
+ title: z.string(),
504
+ readOnly: z.boolean(),
505
+ inputFields: z.array(z.string()),
506
+ description: z.string(),
507
+ })
508
+ : z.object({}).passthrough();
368
509
  // -- list_tool_groups --
369
510
  originalRegisterTool("list_tool_groups", {
370
511
  title: "List tool groups",
371
- description: "List the Frihet ERP tool domains (invoicing, expenses, fiscal/compliance, banking, CRM, " +
372
- "HR/payroll, stay/PMS, POS, intelligence, products, platform) with a one-line blurb and tool " +
512
+ ...oauthConfig,
513
+ ...metaConfig("list_tool_groups"),
514
+ description: `List the available Frihet tool domains (${visibleGroupIds.join(", ")}) with a one-line blurb and tool ` +
373
515
  "count for each. Start here, then use search_tools(query) to find tools and describe_tool(name) " +
374
- "for a tool's full schema. Frihet serves deep ES/EU fiscal + native compliance on demand. " +
516
+ "for a tool's full description and input fields. Only domains present in this reviewed connector are listed. " +
375
517
  "[openWorldHint: false — reads the in-process tool catalog only.] " +
376
518
  "/ Lista los dominios de herramientas de Frihet ERP con descripción y recuento. Empieza aquí.",
377
519
  annotations: META_ANNOTATIONS,
378
- inputSchema: {},
379
- outputSchema: z
380
- .object({})
381
- .passthrough()
382
- .describe("Tool domains with per-group blurb and tool count / Dominios de herramientas con descripción y recuento"),
520
+ inputSchema: securitySchemes ? z.object({}).strict() : {},
521
+ outputSchema: listGroupsOutput.describe("Tool domains with per-group blurb and tool count / Dominios de herramientas con descripción y recuento"),
383
522
  }, async () => {
384
- const out = Object.keys(GROUPS)
523
+ const out = visibleGroupIds
385
524
  .map((id) => ({
386
525
  group: id,
387
- label: GROUPS[id].label,
388
- blurb: GROUPS[id].blurb,
526
+ label: groupMetadata[id].label,
527
+ blurb: groupMetadata[id].blurb,
389
528
  toolCount: groups.get(id)?.length ?? 0,
390
529
  }))
391
530
  .filter((g) => g.toolCount > 0);
@@ -393,12 +532,14 @@ originalRegisterTool, handle) {
393
532
  groups: out,
394
533
  totalGroups: out.length,
395
534
  totalTools: catalog.size,
396
- next: "search_tools(query) to find tools; describe_tool(name) for full schema.",
535
+ next: "search_tools(query) to find tools; describe_tool(name) for full description and input fields.",
397
536
  });
398
537
  });
399
538
  // -- search_tools --
400
539
  originalRegisterTool("search_tools", {
401
540
  title: "Search tools",
541
+ ...oauthConfig,
542
+ ...metaConfig("search_tools"),
402
543
  description: "Find Frihet ERP tools by free-text query (matches tool name, title, summary and group). " +
403
544
  "Returns matching tools with their group, one-line summary, read-only flag and input field names — " +
404
545
  "progressive disclosure so you load only what you need. Optionally filter by group. " +
@@ -406,27 +547,10 @@ originalRegisterTool, handle) {
406
547
  "[openWorldHint: false — reads the in-process tool catalog only.] " +
407
548
  "/ Busca herramientas por texto libre. Devuelve coincidencias con grupo, resumen y campos.",
408
549
  annotations: META_ANNOTATIONS,
409
- inputSchema: {
410
- query: z
411
- .string()
412
- .optional()
413
- .describe("Free-text query — matches tool name, title, summary and group. " +
414
- "Omit to browse a whole group. / Texto libre; omitir para listar un grupo entero."),
415
- group: z
416
- .enum(TOOL_GROUP_IDS)
417
- .optional()
418
- .describe("Restrict results to one tool domain / Filtrar por dominio."),
419
- limit: z
420
- .number()
421
- .int()
422
- .positive()
423
- .optional()
424
- .describe("Max results (default 25) / Máximo de resultados (por defecto 25)."),
425
- },
426
- outputSchema: z
427
- .object({})
428
- .passthrough()
429
- .describe("Matching tools with group, summary, read-only flag and input fields / Herramientas coincidentes con grupo, resumen y campos"),
550
+ inputSchema: securitySchemes
551
+ ? z.object(searchToolsInputShape).strict()
552
+ : searchToolsInputShape,
553
+ outputSchema: searchToolsOutput.describe("Matching tools with group, summary, read-only flag and input fields / Herramientas coincidentes con grupo, resumen y campos"),
430
554
  },
431
555
  // The SDK passes parsed args; we read defensively to stay schema-light.
432
556
  async (args = {}) => {
@@ -461,6 +585,8 @@ originalRegisterTool, handle) {
461
585
  title: e.title,
462
586
  summary: e.summary,
463
587
  readOnly: e.readOnly,
588
+ ...(e.annotations ? { annotations: e.annotations } : {}),
589
+ ...(e.capability ? { capability: e.capability } : {}),
464
590
  inputFields: e.inputFields,
465
591
  score,
466
592
  }));
@@ -469,28 +595,24 @@ originalRegisterTool, handle) {
469
595
  group: groupFilter ?? null,
470
596
  count: scored.length,
471
597
  tools: scored,
472
- next: "describe_tool(name) for full schema, then call the tool by its real name.",
598
+ next: "describe_tool(name) for full description and input fields, then call the tool by its real name.",
473
599
  });
474
600
  });
475
601
  // -- describe_tool --
476
602
  originalRegisterTool("describe_tool", {
477
603
  title: "Describe tool",
604
+ ...oauthConfig,
605
+ ...metaConfig("describe_tool"),
478
606
  description: "Return the full original description, group, read-only flag and input field names for a specific " +
479
607
  "Frihet ERP tool by its exact name (as returned by search_tools). Use this to load a tool's full " +
480
608
  "depth on demand before calling it. " +
481
609
  "[openWorldHint: false — reads the in-process tool catalog only.] " +
482
610
  "/ Devuelve la descripción completa de una herramienta por su nombre exacto.",
483
611
  annotations: META_ANNOTATIONS,
484
- inputSchema: {
485
- name: z
486
- .string()
487
- .describe("Exact tool name as returned by search_tools (e.g. 'create_invoice') / " +
488
- "Nombre exacto de la herramienta devuelto por search_tools."),
489
- },
490
- outputSchema: z
491
- .object({})
492
- .passthrough()
493
- .describe("A tool's full description, group, read-only flag and input field names / Descripción completa de una herramienta"),
612
+ inputSchema: securitySchemes
613
+ ? z.object(describeToolInputShape).strict()
614
+ : describeToolInputShape,
615
+ outputSchema: describeToolOutput.describe("A tool's full description, group, read-only flag and input field names / Descripción completa de una herramienta"),
494
616
  }, async (args = {}) => {
495
617
  const name = typeof args.name === "string" ? args.name.trim() : "";
496
618
  const entry = catalog.get(name);
@@ -520,6 +642,8 @@ originalRegisterTool, handle) {
520
642
  group: entry.group,
521
643
  title: entry.title,
522
644
  readOnly: entry.readOnly,
645
+ ...(entry.annotations ? { annotations: entry.annotations } : {}),
646
+ ...(entry.capability ? { capability: entry.capability } : {}),
523
647
  inputFields: entry.inputFields,
524
648
  description: entry.description,
525
649
  });
@@ -2,17 +2,26 @@
2
2
  * Period close tools for the Frihet MCP server — D4-B megasprint (3 tools).
3
3
  *
4
4
  * Tools:
5
- * 1. period_close_status — get the current accounting period state (open/closing/closed)
5
+ * 1. period_close_status — get the current or selected fiscal-year state
6
6
  * 2. period_close — close a monthly or quarterly period (TRUST AREA)
7
7
  * 3. period_reopen — reopen a closed period with required reason (TRUST AREA)
8
8
  *
9
9
  * REST surface: /v1/periods/current, /v1/periods/{id}, /v1/periods/close, /v1/periods/{id}/reopen
10
10
  *
11
- * Closing a period freezes invoices/expenses/journal entries. Reopening requires a
12
- * compliance reason logged for audit. These are TRUST AREA operations.
11
+ * STATE OF THE WORLD (verified against erp-main functions/src/publicApi/families/
12
+ * periods.ts): only the READ route is implemented. Both WRITE routes answer
13
+ * HTTP 501 NOT_IMPLEMENTED — "deferred to the upcoming fiscal-write wave (Trust,
14
+ * serialized). Use year-end closing in the app for now." Nothing is frozen, and
15
+ * there is no idempotent no-op to rely on, because there is no handler at all.
13
16
  *
14
- * NOTE: ERP backend endpoints land in parallel D4-A wave. 404s propagate as isError
15
- * until backend ships. TODO: confirm callable boundary vs REST shell for closePeriod.
17
+ * The descriptions below therefore state the 501 in the present tense. Do not
18
+ * restore aspirational prose about freezing or idempotency until a handler exists
19
+ * — GAP-13 was exactly that: an agent planning a year-end close read a mechanism
20
+ * the backend never had.
21
+ *
22
+ * `withBackendGuard` converts 404 only (backend-availability.ts isBackendNotFound),
23
+ * so a 501 surfaces through the generic error path as isError with the backend's
24
+ * own NOT_IMPLEMENTED message — an honest failure, not a fake success.
16
25
  */
17
26
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
27
  import type { IFrihetClient } from "../client-interface.js";