@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.
- package/catalog/create-tool/SKILL.md +1 -1
- package/catalog/create-tool/examples/08-tool-with-provider-injection.md +2 -2
- package/catalog/create-tool/examples/22-tool-with-ui-html-template.md +28 -17
- package/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md +10 -7
- package/catalog/create-tool/references/decorator-options.md +1 -0
- package/catalog/create-tool/references/elicitation.md +9 -0
- package/catalog/create-tool/references/error-handling.md +10 -6
- package/catalog/create-tool/references/execution-context.md +17 -12
- package/catalog/create-tool/references/function-style-builder.md +1 -1
- package/catalog/create-tool/references/input-schema.md +2 -0
- package/catalog/create-tool/references/output-schema.md +6 -1
- package/catalog/create-tool/references/throttling.md +7 -8
- package/catalog/create-tool/references/ui-widgets.md +44 -6
- package/catalog/frontmcp-authorities/SKILL.md +1 -0
- package/catalog/frontmcp-authorities/references/custom-evaluators.md +13 -2
- package/catalog/frontmcp-authorities/references/rbac-abac-rebac.md +2 -0
- package/catalog/frontmcp-config/examples/configure-skills-http/inject-instructions.md +6 -4
- package/catalog/frontmcp-config/references/configure-auth.md +1 -0
- package/catalog/frontmcp-config/references/configure-skills-http.md +4 -2
- package/catalog/frontmcp-config/references/configure-throttle-guard-config.md +5 -5
- package/catalog/frontmcp-config/references/configure-throttle.md +49 -22
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +4 -0
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare.md +1 -1
- package/catalog/frontmcp-development/examples/create-plugin-hooks/caching-with-around.md +7 -6
- package/catalog/frontmcp-development/examples/official-plugins/production-multi-plugin-setup.md +2 -2
- package/catalog/frontmcp-development/references/create-plugin-hooks.md +21 -17
- package/catalog/frontmcp-development/references/create-plugin.md +41 -7
- package/catalog/frontmcp-development/references/create-prompt.md +18 -16
- package/catalog/frontmcp-development/references/create-provider.md +3 -3
- package/catalog/frontmcp-development/references/create-resource.md +10 -8
- package/catalog/frontmcp-development/references/create-skill.md +7 -0
- package/catalog/frontmcp-development/references/decorators-guide.md +9 -8
- package/catalog/frontmcp-development/references/official-plugins.md +88 -6
- package/catalog/frontmcp-development/references/openapi-adapter.md +2 -1
- package/catalog/frontmcp-observability/references/metrics-endpoint.md +1 -1
- package/catalog/frontmcp-observability/references/structured-logging.md +35 -0
- package/catalog/frontmcp-testing/examples/test-e2e-handler/tool-call-and-error-e2e.md +3 -1
- package/catalog/frontmcp-testing/references/setup-testing.md +9 -9
- package/catalog/frontmcp-testing/references/test-e2e-handler.md +35 -0
- package/catalog/skills-manifest.json +9 -6
- 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
|
|
769
|
-
|
|
|
770
|
-
| Tool does not appear in `tools/list` MCP response
|
|
771
|
-
| `this.get(Token)` throws `
|
|
772
|
-
| Resource returns 404 / `ResourceNotFoundError`
|
|
773
|
-
| Hook never fires
|
|
774
|
-
| Plugin context extension is `undefined` at runtime
|
|
775
|
-
| Agent `execute()` returns empty result
|
|
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:
|
|
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
|
|
439
|
-
|
|
440
|
-
|
|
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
|
|
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
|
|
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:
|
|
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()`
|
|
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", "
|
|
340
|
+
"tags": ["ui", "ui-widgets", "html-template", "html-tag", "escapeStringResults", "TemplateContext"],
|
|
341
341
|
"features": [
|
|
342
|
-
"Adding a `ui:` block with a function template
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
}
|