@frontmcp/skills 1.8.0 → 1.8.2

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 (41) hide show
  1. package/catalog/create-tool/SKILL.md +1 -1
  2. package/catalog/create-tool/examples/08-tool-with-provider-injection.md +2 -2
  3. package/catalog/create-tool/examples/22-tool-with-ui-html-template.md +28 -17
  4. package/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md +10 -7
  5. package/catalog/create-tool/references/decorator-options.md +1 -0
  6. package/catalog/create-tool/references/elicitation.md +9 -0
  7. package/catalog/create-tool/references/error-handling.md +10 -6
  8. package/catalog/create-tool/references/execution-context.md +17 -12
  9. package/catalog/create-tool/references/function-style-builder.md +1 -1
  10. package/catalog/create-tool/references/input-schema.md +2 -0
  11. package/catalog/create-tool/references/output-schema.md +6 -1
  12. package/catalog/create-tool/references/throttling.md +7 -8
  13. package/catalog/create-tool/references/ui-widgets.md +44 -6
  14. package/catalog/frontmcp-authorities/SKILL.md +1 -0
  15. package/catalog/frontmcp-authorities/references/custom-evaluators.md +13 -2
  16. package/catalog/frontmcp-authorities/references/rbac-abac-rebac.md +2 -0
  17. package/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md +6 -4
  18. package/catalog/frontmcp-config/references/configure-auth.md +1 -0
  19. package/catalog/frontmcp-config/references/configure-skills-http.md +4 -2
  20. package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +5 -5
  21. package/catalog/frontmcp-config/references/configure-throttle.md +49 -22
  22. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +4 -0
  23. package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +1 -1
  24. package/catalog/frontmcp-development/examples/create-plugin-hooks/caching-with-around.md +7 -6
  25. package/catalog/frontmcp-development/examples/official-plugins/production-multi-plugin-setup.md +2 -2
  26. package/catalog/frontmcp-development/references/create-plugin-hooks.md +21 -17
  27. package/catalog/frontmcp-development/references/create-plugin.md +41 -7
  28. package/catalog/frontmcp-development/references/create-prompt.md +18 -16
  29. package/catalog/frontmcp-development/references/create-provider.md +3 -3
  30. package/catalog/frontmcp-development/references/create-resource.md +10 -8
  31. package/catalog/frontmcp-development/references/create-skill.md +7 -0
  32. package/catalog/frontmcp-development/references/decorators-guide.md +9 -8
  33. package/catalog/frontmcp-development/references/official-plugins.md +88 -6
  34. package/catalog/frontmcp-development/references/openapi-adapter.md +2 -1
  35. package/catalog/frontmcp-observability/references/metrics-endpoint.md +1 -1
  36. package/catalog/frontmcp-observability/references/structured-logging.md +35 -0
  37. package/catalog/frontmcp-testing/examples/test-e2e-handler/tool-call-and-error-e2e.md +3 -1
  38. package/catalog/frontmcp-testing/references/setup-testing.md +9 -9
  39. package/catalog/frontmcp-testing/references/test-e2e-handler.md +35 -0
  40. package/catalog/skills-manifest.json +9 -6
  41. package/package.json +1 -1
@@ -85,6 +85,7 @@ FrontMCP uses a hierarchical decorator system. The nesting order is:
85
85
  | `jobs?` | Background jobs/workflows system (`{ enabled, store? }`) |
86
86
  | `throttle?` | Server-level guard config (see note below) |
87
87
  | `pagination?` | List operation pagination (`tools/list` endpoint) |
88
+ | `fetch?` | What `this.fetch()` adds upstream: `forwardCallerTokenTo` / `forwardCustomHeadersTo` origin allow-lists (default: none), `autoInjectTracingHeaders`, `requestTimeout` |
88
89
  | `ui?` | UI rendering config (CDN overrides for widget imports) |
89
90
  | `extApps?` | Widget-to-host MCP Apps communication (host capabilities, session validation) |
90
91
  | `loader?` | Default npm/ESM package loader for `App.esm()` / `App.remote()` apps |
@@ -765,14 +766,14 @@ class AuditHooks {
765
766
 
766
767
  ## Troubleshooting
767
768
 
768
- | Problem | Cause | Solution |
769
- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
770
- | Tool does not appear in `tools/list` MCP response | Tool class is not registered in any `@App({ tools: [...] })` or `@FrontMcp({ tools: [...] })` | Add the tool class to the `tools` array of the appropriate `@App` or `@FrontMcp` decorator |
771
- | `this.get(Token)` throws `DependencyNotFoundError` | The provider for that token is not registered or is registered in a different app scope | Add a `@Provider` for the token in the same `@App` or in `@FrontMcp({ providers: [...] })` for global access |
772
- | Resource returns 404 / `ResourceNotFoundError` | The `uri` in `@Resource` does not match the requested URI, or `uriTemplate` parameters are misaligned | Verify the URI string exactly matches what the client requests; for templates, confirm `{param}` names match |
773
- | Hook never fires | The `flow` string in `@Will`/`@Did`/`@Around`/`@Stage` does not match any registered flow | Check the flow string against valid flows (e.g., `tools:call-tool`, `resources:read-resource`, `resources:list-resources`) |
774
- | Plugin context extension is `undefined` at runtime | The plugin's `installContextExtension` function was not called, or module augmentation is missing | Ensure the plugin is registered and its context extension function runs at startup; verify the `declare module` augmentation exists |
775
- | Agent `execute()` returns empty result | LLM configuration is missing or invalid (wrong model name, missing API key) | Verify `llm.model` and `llm.provider` in `@Agent`, and ensure the provider API key is set in environment variables |
769
+ | Problem | Cause | Solution |
770
+ | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
771
+ | Tool does not appear in `tools/list` MCP response | Tool class is not registered in any `@App({ tools: [...] })` or `@FrontMcp({ tools: [...] })` | Add the tool class to the `tools` array of the appropriate `@App` or `@FrontMcp` decorator |
772
+ | `this.get(Token)` throws `ProviderNotAvailableError` | The provider for that token is not registered or is registered in a different app scope | Add a `@Provider` for the token in the same `@App` or in `@FrontMcp({ providers: [...] })` for global access |
773
+ | Resource returns 404 / `ResourceNotFoundError` | The `uri` in `@Resource` does not match the requested URI, or `uriTemplate` parameters are misaligned | Verify the URI string exactly matches what the client requests; for templates, confirm `{param}` names match |
774
+ | Hook never fires | The `flow` string in `@Will`/`@Did`/`@Around`/`@Stage` does not match any registered flow | Check the flow string against valid flows (e.g., `tools:call-tool`, `resources:read-resource`, `resources:list-resources`) |
775
+ | Plugin context extension is `undefined` at runtime | The plugin's `installContextExtension` function was not called, or module augmentation is missing | Ensure the plugin is registered and its context extension function runs at startup; verify the `declare module` augmentation exists |
776
+ | Agent `execute()` returns empty result | LLM configuration is missing or invalid (wrong model name, missing API key) | Verify `llm.model` and `llm.provider` in `@Agent`, and ensure the provider API key is set in environment variables |
776
777
 
777
778
  ---
778
779
 
@@ -153,6 +153,28 @@ class MyTool extends ToolContext {
153
153
  }
154
154
  ```
155
155
 
156
+ ### Tool Access Policy
157
+
158
+ One policy decides every CodeCall surface: `codecall:search`, `codecall:describe`, `callTool`/`getTool` and the namespace bindings in `codecall:execute`, and `codecall:invoke`. A withheld tool is not indexed, is reported in describe's `notFound`, and is refused at execution.
159
+
160
+ ```typescript
161
+ CodeCallPlugin.init({
162
+ mode: 'codecall_only',
163
+ // `tool` is { name, appId, source, description, tags }; `name` is the tool's own name, never `<appId>:<name>`
164
+ includeTools: (tool) => !tool.name.startsWith('admin:'),
165
+ directCalls: {
166
+ enabled: true,
167
+ allowedTools: ['users:list', 'crm:users:get'], // bare name, or `<appId>:<name>` to pin one app
168
+ },
169
+ });
170
+ ```
171
+
172
+ - Always withheld: `enabledInCodeCall: false` tools, hidden tools (`visibility: 'hidden'` / `hideFromDiscovery`), `visibility: 'internal'` tools, `codecall:*`, and any tool whose name, qualified name or requested spelling starts with `system:`, `internal:` or `__`.
173
+ - `tool.appId` names the owning app for the tools its adapters and plugins provide too, so `includeTools: (tool) => tool.appId !== 'admin'` withholds every tool of app `admin`.
174
+ - `codecall:searchSkills` and `codecall:searchKnowledge` run the SDK's `skills:filter` flow, so a skill a plugin withholds there (a flag-disabled skill, for one) is absent from both.
175
+ - `directCalls.allowedTools` and `directCalls.filter` only narrow the base policy; listing a withheld tool does not make it callable. Unlisted tools are refused.
176
+ - Hiding a tool from search is not the control; the refusal at execution is. Do not rely on `visibleInListTools` or search ranking to protect a tool.
177
+
156
178
  ### Power Features
157
179
 
158
180
  - **TF-IDF Search** -- Term frequency-inverse document frequency scoring indexes tool names, descriptions, and tags. No external embedding service required.
@@ -271,7 +293,9 @@ user. If the data really is shared, use `scope: 'global'`.
271
293
  `tool` included, derive their encryption key from that secret plus the scope identity. A
272
294
  session id is not a secret -- the client knows it and it travels in the `mcp-session-id`
273
295
  header -- so it cannot be the key material on its own. Instances with different secrets cannot
274
- read each other's entries.
296
+ read each other's entries. With none of `REMEMBER_SECRET`, `MCP_MEMORY_SECRET` or
297
+ `MCP_SESSION_SECRET` set in production, the plugin falls back to a random in-memory secret and
298
+ logs a warning once; its encrypted memory is then lost on restart.
275
299
 
276
300
  **Upgrading past that change moves existing `session`, `tool` and `user` entries.** The key
277
301
  derivation change orphans `session` and `tool` ciphertext, and the namespace now percent-encodes
@@ -353,6 +377,11 @@ class AuditedServer {}
353
377
  class WebhookServer {}
354
378
  ```
355
379
 
380
+ **`ApprovalPlugin.init()` registers the approval check itself.** Do not add `ApprovalCheckPlugin`
381
+ to `plugins`; listing it as well is harmless and the check still runs once per call. Require
382
+ 1.8.1 or later: in 1.8.0 and earlier `ApprovalPlugin.init()` registered no check at all, so
383
+ tools marked `approval` ran unapproved.
384
+
356
385
  ### Modes
357
386
 
358
387
  - `recheck` -- Re-evaluates approval status on every tool call. Approval can be granted programmatically via `this.approval.grantSessionApproval()`. Good for interactive approval flows where the user confirms in-band.
@@ -373,6 +402,39 @@ name the context that lets it skip the gate. Set the context when you authentica
373
402
  authInfo.extra.approvalContext = { type: 'project', identifier: resolvedProjectId };
374
403
  ```
375
404
 
405
+ ### How the check decides
406
+
407
+ 1. `skipApproval: true`, or approval not required: the tool runs.
408
+ 2. A recorded **denial** for the caller (session or user scope): refused with state `denied`. A
409
+ denial outranks pre-approved contexts and any session approval.
410
+ 3. The session context is one of `preApprovedContexts`: the tool runs.
411
+ 4. `alwaysPrompt: true`: refused with state `pending`.
412
+ 5. A valid approval for the caller: the tool runs.
413
+ 6. Otherwise refused with state `pending` (or `expired`).
414
+
415
+ A refused call throws `ApprovalRequiredError`; the client receives an error result.
416
+
417
+ Approvals are looked up by the tool's full name, `<owner id>:<tool name>`, so pass that name to
418
+ `this.approval` grant and check methods. The owner is the app that declares the tool, or the
419
+ adapter or plugin that provides it (`my-app:file_write` for a tool declared on app `my-app`,
420
+ `github-api:create_issue` for one its `github-api` adapter provides). Session approvals belong to a
421
+ session the server verified (`FrontMcpContext.verifiedSessionId`). A stateless request has none -- it
422
+ carries the shared stateless session id, or sends no `mcp-session-id` and runs under a fresh
423
+ per-request id -- so it is keyed by the authenticated principal (`authInfo.extra.userId`, then
424
+ `authInfo.extra.sub`, then `authInfo.clientId`): a grant made in one stateless request is found by
425
+ the same principal's next request and by no other principal. A stateless call with no principal
426
+ cannot hold a session approval. Releases up to 1.8.1 keyed a request without `mcp-session-id` by
427
+ its per-request id, so the grant was never found again
428
+ ([#597](https://github.com/agentfront/frontmcp/issues/597)).
429
+
430
+ Installed on an app, `ApprovalPlugin` gates only that app's tools (including those its adapters
431
+ and plugins provide) against its own store, so two apps can each install it with separate stores.
432
+ Installed on the server, it gates every tool; a tool both gate must pass each store's check.
433
+ `this.approval` resolves the `ApprovalService` of the nearest `ApprovalPlugin` -- the one the
434
+ tool's own app installed, otherwise the server's -- so with two apps each installing it, a grant
435
+ or check in one app's tool uses that app's store. Releases up to 1.8.1 resolved the store of the
436
+ app registered last ([#600](https://github.com/agentfront/frontmcp/issues/600)).
437
+
376
438
  ### Using `this.approval` in Tools
377
439
 
378
440
  ```typescript
@@ -405,11 +467,13 @@ class DangerousActionTool extends ToolContext {
405
467
  ### Per-Tool Approval Metadata
406
468
 
407
469
  ```typescript
470
+ import { ApprovalScope } from '@frontmcp/plugin-approval';
471
+
408
472
  @Tool({
409
473
  name: 'file_write',
410
474
  approval: {
411
475
  required: true,
412
- defaultScope: 'session', // 'session' | 'user' | 'time-limited'
476
+ defaultScope: ApprovalScope.SESSION, // SESSION | USER | TIME_LIMITED | TOOL_SPECIFIC | CONTEXT_SPECIFIC
413
477
  category: 'write',
414
478
  riskLevel: 'medium', // 'low' | 'medium' | 'high' | 'critical'
415
479
  approvalMessage: 'Allow file writing for this session?',
@@ -435,9 +499,13 @@ asking -- a profile, a balance, a tenant's records, anything filtered by the cal
435
499
  permissions -- and a key built only from the tool and its arguments serves the first caller's
436
500
  response to everyone else.
437
501
 
438
- The identity is the authenticated subject (`sub` / `userId`), then the client id, then the
439
- session. A call with no identity at all gets a key of its own rather than one shared with
440
- every other identity-less caller.
502
+ The identity is the subject your auth layer puts in `authInfo.extra` (`sub` / `userId`), then
503
+ the client id, then the session. For a request the SDK verified, the client id is the token's
504
+ `sub`, or `anon:<id>` for an anonymous session. Only a session the server verified
505
+ (`FrontMcpContext.verifiedSessionId`) is an identity: the session id the stateless HTTP transport
506
+ gives every request, the fresh per-request id of a request without `mcp-session-id`, and an
507
+ `mcp-session-id` the server did not accept are not. A call with no identity at all gets a key of
508
+ its own rather than one shared with every other identity-less caller.
441
509
 
442
510
  Set `keyByIdentity: false` **only** when every caller would get byte-identical output: public
443
511
  reference data, a currency table, a static document.
@@ -688,7 +756,21 @@ class ExperimentalTool extends ToolContext {
688
756
  }
689
757
  ```
690
758
 
691
- The plugin hooks into listing and execution flows for tools, resources, prompts, and skills. When a flag evaluates to `false`, the corresponding entry is filtered from list results and direct invocation returns an error.
759
+ The plugin hooks into listing and execution flows for tools, resources, resource templates, prompts, and skills. When a flag evaluates to `false`, the corresponding entry is filtered from list results and direct access is refused:
760
+
761
+ | Capability | Hidden from | Refused on |
762
+ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
763
+ | Tool | `tools/list`, and the `toolName` completion of `ui://widget/{toolName}.html` | `tools/call` |
764
+ | Resource | `resources/list` | `resources/read`, `completion/complete` |
765
+ | Resource template | `resources/templates/list` | `resources/read` of any URI it matches, `completion/complete` |
766
+ | Prompt | `prompts/list` | `prompts/get`, `completion/complete` |
767
+ | Skill | `skills/search`, `skills/list`, `skill://index.json`, its `skill://<path>/SKILL.md` entry in `resources/list`, `GET /skills`, `/llm.txt`, `/llm_full.txt`, `codecall:searchSkills`, `codecall:searchKnowledge` | `skills/load`, `skill://<path>/SKILL.md` and its files, `GET /skills/{id}` (same answer as a nonexistent skill) |
768
+
769
+ The `toolName` completion of `ui://widget/{toolName}.html` runs the caller's `tools:list-tools` flow, so it offers only the UI tools `tools/list` shows that caller -- a flagged-off tool is left out there too ([#596](https://github.com/agentfront/frontmcp/issues/596)).
770
+
771
+ The skill catalog in the `initialize` instructions (and the SEP-2640 `skill://` hints under `skillsConfig.sep2640InInstructions`) is filtered for the initializing client too, so a flag-disabled skill's name and description never appear there ([#603](https://github.com/agentfront/frontmcp/issues/603)). A skill's `skill://<path>/SKILL.md` entry in `resources/list` is gated by the skill that path serves now -- replace a skill at the same path and the entry takes the new skill's flag ([#606](https://github.com/agentfront/frontmcp/issues/606)).
772
+
773
+ Installed on an `@App`, the gates cover every capability that app provides, including tools, resources and prompts contributed by its adapters (e.g. an OpenAPI adapter) and plugins. Its tool, resource, prompt and completion gates do not run for other apps' capabilities -- install it in `@FrontMcp({ plugins })` to gate every app. Resources and prompts served outside every app (the SEP-2640 `skill://` resources) are gated by every installed copy. Skills are gated through the `skills:filter` flow, which every skill surface runs -- as the calling user on every transport, stdio and in-memory included; custom plugins can hook `Did('filterSkills')` on it the same way, and reuse `filterServableSkills(scope, skills)` from `@frontmcp/sdk` to serve skills from a surface of their own.
692
774
 
693
775
  ---
694
776
 
@@ -318,7 +318,8 @@ FrontMCP's secure defaults:
318
318
 
319
319
  The rules above cover _loading the spec_. **Calling** a generated tool is a different fetch,
320
320
  and it never follows redirects either (**GHSA-qh67-4345-cw2q**): the adapter sets
321
- `redirect: 'manual'` and refuses any 3xx with `OPENAPI_REDIRECT_NOT_FOLLOWED`.
321
+ `redirect: 'manual'` and refuses any 3xx -- and the status-0 `opaqueredirect` response browser
322
+ runtimes return instead -- with `OPENAPI_REDIRECT_NOT_FOLLOWED`.
322
323
 
323
324
  Following one would send the request to a destination the _upstream_ chose. Only `baseUrl` is
324
325
  validated, and only for its scheme, so a 3xx is an unvalidated hop -- including to an internal
@@ -27,7 +27,7 @@ import { FrontMcp } from '@frontmcp/sdk';
27
27
  class Server {}
28
28
  ```
29
29
 
30
- Then scrape: `curl http://localhost:3001/metrics` — Content-Type is the canonical Prometheus `text/plain; version=0.0.4; charset=utf-8`.
30
+ Then scrape: `curl http://localhost:3000/metrics` (the default port is `PORT`, else 3000) — Content-Type is the canonical Prometheus `text/plain; version=0.0.4; charset=utf-8`.
31
31
 
32
32
  ## Configuration
33
33
 
@@ -99,6 +99,41 @@ logging: {
99
99
 
100
100
  Redaction is recursive (handles nested objects) and case-insensitive.
101
101
 
102
+ ## CodeCall Audit Events
103
+
104
+ If `CodeCallPlugin` is installed, it emits a structured audit event at every meaningful point in a
105
+ script execution. The plugin registers the bridge itself, so the events reach your normal log
106
+ output at `info` under the `codecall:audit` prefix with no wiring.
107
+
108
+ Event families, all carrying `executionId` for correlation:
109
+
110
+ - `codecall:execution:start` / `:success` / `:failure` / `:timeout`
111
+ - `codecall:tool:call:start` / `:success` / `:failure`
112
+ - `codecall:security:self-reference` / `:access-denied` / `:ast-blocked`
113
+ - `codecall:search:performed` / `codecall:describe:performed` / `codecall:invoke:performed`
114
+
115
+ No event carries script source, tool arguments, tool results, or query text — a script is reduced
116
+ to `scriptHash` + `scriptLength`, a query to `queryLength`. Do not add those fields when building
117
+ on this; the omission is what makes the events safe to emit at `info`.
118
+
119
+ To route events elsewhere, subscribe rather than parsing logs:
120
+
121
+ ```typescript
122
+ import { AUDIT_EVENT_TYPES, AuditLoggerService, type AuditEvent } from '@frontmcp/plugin-codecall';
123
+
124
+ const audit = scope.providers.get(AuditLoggerService);
125
+ const unsubscribe = audit.subscribe((event: AuditEvent) => myShipper.send(event));
126
+ ```
127
+
128
+ Fan-out is synchronous and on the execution hot path — hand off to a queue, never do network I/O
129
+ inside the listener. Note the file transport writes only the message, so structured fields are
130
+ dropped there; use the structured transport or subscribe directly.
131
+
132
+ **Over stdio:** stdout carries the MCP JSON-RPC frames, so nothing else may be written there.
133
+ `runStdio()` redirects the stdout-bound `console` methods to stderr, and the NDJSON `stdout` sink
134
+ defaults to stderr when `FRONTMCP_STDIO` is set. Never configure a sink with an explicit
135
+ `stream: process.stdout` on a stdio server — an explicit stream overrides the guard.
136
+
102
137
  ## Examples
103
138
 
104
139
  | Example | Level | Description |
@@ -8,6 +8,7 @@ features:
8
8
  - 'Calling tools via `client.tools.call(name, args)` and asserting success with `toBeSuccessful()`'
9
9
  - 'Asserting text content with the `toHaveTextContent()` matcher'
10
10
  - 'Asserting error results with `toBeError()` for invalid input and unknown tools'
11
+ - 'Matching the tool error code: invalid input is an `isError` result with `_meta.code: "INVALID_INPUT"`, so `toBeError(''INVALID_INPUT'')` matches it (a numeric code matches JSON-RPC errors only)'
11
12
  - 'Testing edge cases like zero values'
12
13
  ---
13
14
 
@@ -53,7 +54,7 @@ describe('Tool Call E2E', () => {
53
54
 
54
55
  it('returns an error for invalid input', async () => {
55
56
  const result = await client.tools.call('add_numbers', { a: 'bad' });
56
- expect(result).toBeError();
57
+ expect(result).toBeError('INVALID_INPUT');
57
58
  });
58
59
 
59
60
  it('returns an error for a nonexistent tool', async () => {
@@ -74,6 +75,7 @@ describe('Tool Call E2E', () => {
74
75
  - Calling tools via `client.tools.call(name, args)` and asserting success with `toBeSuccessful()`
75
76
  - Asserting text content with the `toHaveTextContent()` matcher
76
77
  - Asserting error results with `toBeError()` for invalid input and unknown tools
78
+ - Matching the tool error code: invalid input is an `isError` result with `_meta.code: "INVALID_INPUT"`, so `toBeError('INVALID_INPUT')` matches it (a numeric code matches JSON-RPC errors only)
77
79
  - Testing edge cases like zero values
78
80
 
79
81
  ## Related
@@ -421,15 +421,15 @@ A nested `test.describe` inherits an outer skip. The `(name, fn)` form still ski
421
421
  import { expect } from '@frontmcp/testing';
422
422
  ```
423
423
 
424
- | Matcher | Asserts |
425
- | ------------------------- | ----------------------------------------------------- |
426
- | `toContainTool(name)` | Tools list includes a tool with the given name |
427
- | `toContainResource(uri)` | Resources list includes a resource with the given URI |
428
- | `toContainPrompt(name)` | Prompts list includes a prompt with the given name |
429
- | `toBeSuccessful()` | Tool call result is not an error |
430
- | `toBeError()` | Tool call result is an MCP error |
431
- | `toHaveTextContent(text)` | Result contains text content matching the string |
432
- | `toHaveMimeType(mime)` | Resource content has the expected MIME type |
424
+ | Matcher | Asserts |
425
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------ |
426
+ | `toContainTool(name)` | Tools list includes a tool with the given name |
427
+ | `toContainResource(uri)` | Resources list includes a resource with the given URI |
428
+ | `toContainPrompt(name)` | Prompts list includes a prompt with the given name |
429
+ | `toBeSuccessful()` | Tool call result is not an error |
430
+ | `toBeError(code?)` | Result is an error; a string code matches `_meta.code` (`'INVALID_INPUT'`), a number matches a JSON-RPC error code |
431
+ | `toHaveTextContent(text)` | Result contains text content matching the string |
432
+ | `toHaveMimeType(mime)` | Resource content has the expected MIME type |
433
433
 
434
434
  ## Running Tests with Nx
435
435
 
@@ -67,6 +67,41 @@ describe('Server E2E', () => {
67
67
  });
68
68
  ```
69
69
 
70
+ ## Error Codes
71
+
72
+ Errors raised while a tool runs, including invalid input, come back as a tool result with `isError: true` and a string `_meta.code`, not as a JSON-RPC error. Match them with the string form, and keep the numeric form for JSON-RPC errors:
73
+
74
+ ```typescript
75
+ expect(await client.tools.call('add_numbers', { a: 5 })).toBeError('INVALID_INPUT');
76
+ expect(await client.tools.call('nonexistent_tool', {})).toBeError('TOOL_NOT_FOUND');
77
+ expect(await client.prompts.get('nonexistent_prompt')).toBeError(-32602);
78
+ ```
79
+
80
+ A tool without an `outputSchema` that returns a plain number sends `{ value: 8 }`, so assert `expect(result.json()).toEqual({ value: 8 })`.
81
+
82
+ ## Notifications and Progress
83
+
84
+ After `initialize`, `McpTestClient` opens the session's notification stream (a `GET` on the MCP endpoint) and records what the server sends there and on each request's own response:
85
+
86
+ ```typescript
87
+ it('records progress and log messages', async () => {
88
+ const progress = client.notifications.collectProgress(); // tools.call now sends a _meta.progressToken
89
+ const notifications = client.notifications.collect();
90
+ await client.raw.request({ jsonrpc: '2.0', id: 1, method: 'logging/setLevel', params: { level: 'info' } });
91
+
92
+ await client.tools.call('import_files', {});
93
+
94
+ await progress.waitForComplete(5000);
95
+ expect(progress.all.length).toBeGreaterThan(0);
96
+ await notifications.waitFor('notifications/message', 5000);
97
+ });
98
+ ```
99
+
100
+ - Pass `{ progressToken }` as the third argument of `tools.call()` to choose the token yourself.
101
+ - The server sends `notifications/message` (from `this.notify()`) only after `logging/setLevel`.
102
+ - Session-stream notifications can arrive after the call's result, so wait with `waitFor()` / `waitForComplete()` before asserting.
103
+ - `McpTestClient` speaks `2025-06-18` by default. `2026-07-28` has no `initialize` handshake, so `withProtocolVersion('2026-07-28')` makes `build()` throw.
104
+
70
105
  ## Examples
71
106
 
72
107
  | Example | Level | Description |
@@ -74,7 +74,7 @@
74
74
  },
75
75
  {
76
76
  "name": "ui-widgets",
77
- "description": "@Tool({ ui }) \u2014 template formats, servingMode, host-detect resourceMode, CSP, widgetAccessible, MCP Apps spec."
77
+ "description": "@Tool({ ui }) \u2014 template formats, trusted markup (html / escapeStringResults), servingMode, host-detect resourceMode, CSP, widgetAccessible, MCP Apps spec."
78
78
  },
79
79
  {
80
80
  "name": "annotations",
@@ -337,11 +337,12 @@
337
337
  "name": "22-tool-with-ui-html-template",
338
338
  "level": "intermediate",
339
339
  "description": "Tool with an inline HTML function template \u2014 `ui: { template: (ctx) => '<div>\u2026</div>' }` \u2014 for a quick widget that doesn't need a separate `.tsx` file.",
340
- "tags": ["ui", "ui-widgets", "html-template", "escapeHtml", "TemplateContext"],
340
+ "tags": ["ui", "ui-widgets", "html-template", "html-tag", "escapeStringResults", "TemplateContext"],
341
341
  "features": [
342
- "Adding a `ui:` block with a function template `(ctx: TemplateContext<In, Out>) => string`",
342
+ "Adding a `ui:` block with a function template that returns markup built with the `ctx.helpers.html` tagged template",
343
343
  "Annotating `ctx` explicitly to dodge the TS7006 inference gap on the union `ui.template` type",
344
- "Always escaping user-controlled output with `ctx.helpers.escapeHtml(...)` so the widget can't XSS itself",
344
+ "Letting `ctx.helpers.html` escape every interpolated value so tool output can't inject markup into the widget",
345
+ "Opting in to `escapeStringResults: true` so a plain string result is escaped \u2014 the default from FrontMCP 1.9",
345
346
  "Reading from `ctx.output` and `ctx.helpers` \u2014 the typed runtime context the template renderer hands you"
346
347
  ]
347
348
  },
@@ -365,7 +366,7 @@
365
366
  "features": [
366
367
  "Restricting the widget's outbound `fetch` via `ui.csp.connectDomains` (emitted on the resource per #455)",
367
368
  "Opting the widget into cross-tool calls with `widgetAccessible: true` and using `window.FrontMcpBridge.callTool(name, args)` instead of host-specific APIs",
368
- "Embedding initial data into the widget's inline `<script>` safely via `ctx.helpers.jsonEmbed(...)` (escapes `</script>`)",
369
+ "Building the markup with `ctx.helpers.html` and embedding initial data into the inline `<script>` via `trustedHtml(jsonEmbed(...))` (`jsonEmbed` escapes `<`, `>` and `&`)",
369
370
  "Surfacing in-flight status via `invocationStatus.invoking` / `invoked` so the host UI shows feedback"
370
371
  ]
371
372
  },
@@ -1069,7 +1070,8 @@
1069
1070
  "Top-level `instructions` on `@FrontMcp` exposes a global system prompt to MCP clients",
1070
1071
  "`skillsConfig.injectInstructions: 'append'` adds the skill catalog summary after the user prompt",
1071
1072
  "Dynamic skills are picked up because the composer runs on every initialize request",
1072
- "Catalog summary is bounded at 16 KB with a truncation footer pointing at skill://catalog"
1073
+ "The catalog only names skills the initializing caller may see (skill authorities and the `skills:filter` flow, e.g. feature flags)",
1074
+ "Catalog summary is bounded at 16 KB with a truncation footer pointing at skill://index.json"
1073
1075
  ]
1074
1076
  },
1075
1077
  {
@@ -3449,6 +3451,7 @@
3449
3451
  "Calling tools via `client.tools.call(name, args)` and asserting success with `toBeSuccessful()`",
3450
3452
  "Asserting text content with the `toHaveTextContent()` matcher",
3451
3453
  "Asserting error results with `toBeError()` for invalid input and unknown tools",
3454
+ "Matching the tool error code: invalid input is an `isError` result with `_meta.code: \"INVALID_INPUT\"`, so `toBeError('INVALID_INPUT')` matches it (a numeric code matches JSON-RPC errors only)",
3452
3455
  "Testing edge cases like zero values"
3453
3456
  ]
3454
3457
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontmcp/skills",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
4
4
  "description": "Curated skills catalog for FrontMCP projects",
5
5
  "author": "AgentFront <info@agentfront.dev>",
6
6
  "homepage": "https://docs.agentfront.dev",