@frontmcp/skills 1.3.0 → 1.4.1
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/README.md +38 -29
- package/catalog/TEMPLATE.md +26 -0
- package/catalog/create-tool/SKILL.md +318 -0
- package/catalog/create-tool/examples/01-basic-class-tool.md +112 -0
- package/catalog/create-tool/examples/02-basic-function-tool.md +80 -0
- package/catalog/create-tool/examples/03-tool-with-zod-shape-output.md +78 -0
- package/catalog/create-tool/examples/04-tool-with-zod-schema-output.md +97 -0
- package/catalog/create-tool/examples/05-tool-with-primitive-output.md +93 -0
- package/catalog/create-tool/examples/06-tool-with-media-output.md +109 -0
- package/catalog/create-tool/examples/08-tool-with-provider-injection.md +110 -0
- package/catalog/create-tool/examples/09-tool-with-multiple-providers.md +107 -0
- package/catalog/create-tool/examples/11-tool-with-fetch.md +94 -0
- package/catalog/create-tool/examples/12-tool-with-fetch-and-retries.md +115 -0
- package/catalog/create-tool/examples/13-tool-with-single-auth-provider.md +85 -0
- package/catalog/create-tool/examples/14-tool-with-multiple-auth-providers.md +105 -0
- package/catalog/create-tool/examples/15-tool-with-credential-vault.md +115 -0
- package/catalog/create-tool/examples/16-tool-with-rate-limit.md +71 -0
- package/catalog/create-tool/examples/17-tool-with-concurrency-and-timeout.md +101 -0
- package/catalog/create-tool/examples/18-tool-with-progress-and-notify.md +96 -0
- package/catalog/create-tool/examples/19-tool-with-elicitation.md +102 -0
- package/catalog/create-tool/examples/20-tool-with-annotations.md +125 -0
- package/catalog/create-tool/examples/21-tool-with-availability-constraints.md +107 -0
- package/catalog/create-tool/examples/22-tool-with-ui-html-template.md +93 -0
- package/catalog/create-tool/examples/23-tool-with-ui-filesource-tsx.md +112 -0
- package/catalog/create-tool/examples/24-tool-with-ui-csp-and-bridge.md +127 -0
- package/catalog/create-tool/examples/25-tool-handing-off-to-job.md +143 -0
- package/catalog/create-tool/examples/26-tool-with-resource-link-output.md +94 -0
- package/catalog/create-tool/examples/27-tool-with-examples-metadata.md +90 -0
- package/catalog/create-tool/references/annotations.md +96 -0
- package/catalog/create-tool/references/auth-providers.md +167 -0
- package/catalog/create-tool/references/availability.md +106 -0
- package/catalog/create-tool/references/decorator-options.md +95 -0
- package/catalog/create-tool/references/derived-types.md +102 -0
- package/catalog/create-tool/references/elicitation.md +128 -0
- package/catalog/create-tool/references/error-handling.md +128 -0
- package/catalog/create-tool/references/execution-context.md +158 -0
- package/catalog/create-tool/references/file-layout.md +96 -0
- package/catalog/create-tool/references/function-style-builder.md +118 -0
- package/catalog/create-tool/references/input-schema.md +141 -0
- package/catalog/create-tool/references/output-schema.md +175 -0
- package/catalog/create-tool/references/quick-start.md +124 -0
- package/catalog/create-tool/references/registration.md +132 -0
- package/catalog/create-tool/references/remote-and-esm.md +68 -0
- package/catalog/create-tool/references/testing.md +59 -0
- package/catalog/create-tool/references/throttling.md +109 -0
- package/catalog/create-tool/references/ui-widgets.md +198 -0
- package/catalog/create-tool/rules/always-define-output-schema.md +77 -0
- package/catalog/create-tool/rules/derive-execute-types.md +57 -0
- package/catalog/create-tool/rules/input-schema-is-raw-shape.md +76 -0
- package/catalog/create-tool/rules/no-toolcontext-generics.md +50 -0
- package/catalog/create-tool/rules/no-try-catch-around-execute.md +79 -0
- package/catalog/create-tool/rules/register-in-app.md +76 -0
- package/catalog/create-tool/rules/snake-case-tool-names.md +45 -0
- package/catalog/create-tool/rules/use-this-fail-for-business-errors.md +75 -0
- package/catalog/create-tool/rules/widget-paths-anchor-with-import-meta-url.md +76 -0
- package/catalog/create-tool/rules/widget-resource-mode-host-detect.md +61 -0
- package/catalog/frontmcp-auth-ui/SKILL.md +146 -0
- package/catalog/frontmcp-auth-ui/examples/custom-auth-ui/login-slot.md +97 -0
- package/catalog/frontmcp-auth-ui/examples/custom-auth-ui/multi-step-auth-extra.md +133 -0
- package/catalog/frontmcp-auth-ui/references/custom-auth-ui.md +162 -0
- package/catalog/frontmcp-authorities/SKILL.md +55 -18
- package/catalog/frontmcp-authorities/references/authority-profiles.md +25 -1
- package/catalog/frontmcp-authorities/references/custom-evaluators.md +1 -1
- package/catalog/frontmcp-authorities/references/rbac-abac-rebac.md +9 -0
- package/catalog/frontmcp-channels/SKILL.md +7 -1
- package/catalog/frontmcp-config/SKILL.md +9 -2
- package/catalog/frontmcp-config/examples/configure-auth/local-credential-vault.md +94 -0
- package/catalog/frontmcp-config/examples/configure-auth/local-secure-store.md +138 -0
- package/catalog/frontmcp-config/examples/configure-auth/remote-oauth-with-vault.md +45 -23
- package/catalog/frontmcp-config/examples/configure-auth-modes/local-behind-tunnel.md +73 -0
- package/catalog/frontmcp-config/examples/configure-auth-modes/local-consent-enforcement.md +87 -0
- package/catalog/frontmcp-config/examples/configure-auth-modes/local-dcr-control.md +67 -0
- package/catalog/frontmcp-config/examples/configure-auth-modes/local-minimal.md +62 -0
- package/catalog/frontmcp-config/examples/configure-auth-modes/local-multi-provider-orchestration.md +93 -0
- package/catalog/frontmcp-config/examples/configure-auth-modes/local-self-signed-tokens.md +18 -20
- package/catalog/frontmcp-config/examples/configure-auth-modes/local-single-operator.md +66 -0
- package/catalog/frontmcp-config/examples/configure-auth-modes/remote-enterprise-oauth.md +37 -23
- package/catalog/frontmcp-config/examples/configure-http/custom-http-routes.md +98 -0
- package/catalog/frontmcp-config/examples/configure-skills-http/audit-log-redis.md +17 -9
- package/catalog/frontmcp-config/references/configure-auth-modes.md +86 -23
- package/catalog/frontmcp-config/references/configure-auth.md +296 -50
- package/catalog/frontmcp-config/references/configure-http.md +149 -15
- package/catalog/frontmcp-deployment/SKILL.md +15 -13
- package/catalog/frontmcp-deployment/references/deploy-manifest-yaml.md +308 -0
- package/catalog/frontmcp-deployment/references/deploy-to-cloudflare-skills-only.md +174 -0
- package/catalog/frontmcp-deployment/references/mcp-client-integration.md +38 -2
- package/catalog/frontmcp-development/SKILL.md +30 -44
- package/catalog/frontmcp-development/references/decorators-guide.md +15 -15
- package/catalog/frontmcp-extensibility/SKILL.md +1 -1
- package/catalog/frontmcp-extensibility/examples/skill-audit-log/verify-chain.md +8 -6
- package/catalog/frontmcp-extensibility/references/skill-audit-log.md +7 -2
- package/catalog/frontmcp-guides/SKILL.md +1 -1
- package/catalog/frontmcp-observability/SKILL.md +1 -1
- package/catalog/frontmcp-production-readiness/SKILL.md +1 -1
- package/catalog/frontmcp-production-readiness/examples/common-checklist/security-hardening.md +3 -2
- package/catalog/frontmcp-setup/SKILL.md +1 -1
- package/catalog/frontmcp-setup/examples/multi-app-composition/per-app-auth-and-isolation.md +7 -4
- package/catalog/frontmcp-setup/references/multi-app-composition.md +6 -5
- package/catalog/frontmcp-testing/SKILL.md +9 -1
- package/catalog/frontmcp-testing/references/test-auth.md +24 -0
- package/catalog/skills-manifest.json +653 -149
- package/package.json +1 -1
- package/src/manifest.d.ts +72 -1
- package/src/manifest.js +4 -1
- package/src/manifest.js.map +1 -1
- package/catalog/frontmcp-development/examples/create-tool/basic-class-tool.md +0 -80
- package/catalog/frontmcp-development/examples/create-tool/tool-with-di-and-errors.md +0 -132
- package/catalog/frontmcp-development/examples/create-tool/tool-with-rate-limiting-and-progress.md +0 -110
- package/catalog/frontmcp-development/examples/create-tool-annotations/destructive-delete-tool.md +0 -92
- package/catalog/frontmcp-development/examples/create-tool-annotations/readonly-query-tool.md +0 -59
- package/catalog/frontmcp-development/examples/create-tool-output-schema-types/primitive-and-media-outputs.md +0 -101
- package/catalog/frontmcp-development/examples/create-tool-output-schema-types/zod-raw-shape-output.md +0 -62
- package/catalog/frontmcp-development/examples/create-tool-output-schema-types/zod-schema-advanced-output.md +0 -101
- package/catalog/frontmcp-development/references/create-tool-annotations.md +0 -48
- package/catalog/frontmcp-development/references/create-tool-output-schema-types.md +0 -71
- package/catalog/frontmcp-development/references/create-tool.md +0 -806
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 11-tool-with-fetch
|
|
3
|
+
level: intermediate
|
|
4
|
+
description: 'Tool calling an external HTTP API with `this.fetch` — context propagation, status-code handling, and bounding the call with a tool `timeout`.'
|
|
5
|
+
tags: [fetch, http, external-api, error-handling]
|
|
6
|
+
features:
|
|
7
|
+
- 'Using `this.fetch(url, init?)` so trace context propagates to the upstream service'
|
|
8
|
+
- 'Translating non-2xx HTTP responses into `PublicMcpError` so the MCP client gets a clean error'
|
|
9
|
+
- "Bounding the call with a tool `timeout` (and `this.fetch`'s built-in per-request timeout) — without relying on a non-existent context abort signal"
|
|
10
|
+
- "Letting genuine network errors (DNS failure, ECONNREFUSED) propagate to the framework's error flow"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Tool With Fetch
|
|
14
|
+
|
|
15
|
+
Tool calling an external HTTP API with `this.fetch` — context propagation, status-code handling, and bounding the call with a tool `timeout`.
|
|
16
|
+
|
|
17
|
+
`this.fetch` is the standard `fetch` plus trace-context propagation. Always use it instead of bare `fetch` so distributed tracing stitches the upstream call into the same trace as the MCP request.
|
|
18
|
+
|
|
19
|
+
## Code
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// src/apps/main/tools/get-weather.tool.ts
|
|
23
|
+
import { PublicMcpError, ResourceNotFoundError, Tool, ToolContext, z } from '@frontmcp/sdk';
|
|
24
|
+
|
|
25
|
+
const inputSchema = {
|
|
26
|
+
city: z.string().describe('City name, e.g. "Seattle"'),
|
|
27
|
+
units: z.enum(['celsius', 'fahrenheit']).default('fahrenheit'),
|
|
28
|
+
};
|
|
29
|
+
const outputSchema = {
|
|
30
|
+
city: z.string(),
|
|
31
|
+
temperature: z.number(),
|
|
32
|
+
conditions: z.string(),
|
|
33
|
+
units: z.enum(['celsius', 'fahrenheit']),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
@Tool({
|
|
37
|
+
name: 'get_weather',
|
|
38
|
+
description: 'Current weather from api.weather.example',
|
|
39
|
+
inputSchema,
|
|
40
|
+
outputSchema,
|
|
41
|
+
timeout: { executeMs: 10_000 }, // cap the whole call if the upstream hangs
|
|
42
|
+
})
|
|
43
|
+
export class GetWeatherTool extends ToolContext {
|
|
44
|
+
async execute(input: { city: string; units: 'celsius' | 'fahrenheit' }) {
|
|
45
|
+
const url = new URL('https://api.weather.example/v1/current');
|
|
46
|
+
url.searchParams.set('city', input.city);
|
|
47
|
+
url.searchParams.set('units', input.units);
|
|
48
|
+
|
|
49
|
+
const response = await this.fetch(url, {
|
|
50
|
+
headers: { accept: 'application/json' },
|
|
51
|
+
// `this.fetch` applies its own request timeout (default 30s). The tool-level
|
|
52
|
+
// `timeout` below caps the whole execute() at the framework level.
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (response.status === 404) {
|
|
56
|
+
this.fail(new ResourceNotFoundError(`weather:${input.city}`));
|
|
57
|
+
}
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
this.fail(new PublicMcpError(`Weather API returned ${response.status} ${response.statusText}`));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const body = (await response.json()) as { temp: number; summary: string };
|
|
63
|
+
return {
|
|
64
|
+
city: input.city,
|
|
65
|
+
temperature: body.temp,
|
|
66
|
+
conditions: body.summary,
|
|
67
|
+
units: input.units,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## What This Demonstrates
|
|
74
|
+
|
|
75
|
+
- Using `this.fetch(url, init?)` so trace context propagates to the upstream service
|
|
76
|
+
- Translating non-2xx HTTP responses into `PublicMcpError` so the MCP client gets a clean error
|
|
77
|
+
- Bounding the call with a tool `timeout` (and `this.fetch`'s built-in per-request timeout) — without relying on a non-existent context abort signal
|
|
78
|
+
- Letting genuine network errors (DNS failure, ECONNREFUSED) propagate to the framework's error flow
|
|
79
|
+
|
|
80
|
+
## Don't do this
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// ❌ swallows network errors, hides infrastructure problems from observability
|
|
84
|
+
async execute(input) {
|
|
85
|
+
try {
|
|
86
|
+
const response = await this.fetch(url);
|
|
87
|
+
return await response.json();
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return { error: String(err) };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Let infrastructure errors propagate. The framework wraps them in `InternalError` (`-32603`) for the client and logs them properly for ops. Only convert specific business-level conditions (status codes you know about) to `this.fail`.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 12-tool-with-fetch-and-retries
|
|
3
|
+
level: advanced
|
|
4
|
+
description: 'Tool calling a flaky external API with exponential backoff retries, an Idempotency-Key for safety, and respect for `Retry-After` on 429s.'
|
|
5
|
+
tags: [fetch, retries, exponential-backoff, idempotency-key, 429-rate-limit]
|
|
6
|
+
features:
|
|
7
|
+
- Retrying on transient errors (5xx + 429) with exponential backoff plus jitter
|
|
8
|
+
- "Generating an `Idempotency-Key` so retried POSTs don't duplicate side effects on the upstream"
|
|
9
|
+
- 'Respecting an upstream `Retry-After` header on 429 responses instead of guessing'
|
|
10
|
+
- "Capping the total retry budget with `timeout: { executeMs }` so a wedged upstream can't hang the call indefinitely"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Tool With Fetch And Retries
|
|
14
|
+
|
|
15
|
+
Tool calling a flaky external API with exponential backoff retries, an Idempotency-Key for safety, and respect for `Retry-After` on 429s.
|
|
16
|
+
|
|
17
|
+
Real upstreams flake. This shows the pattern that survives them: retry the right errors, back off correctly, generate an idempotency key so retried POSTs don't double-fire, and cap the whole thing with `timeout`.
|
|
18
|
+
|
|
19
|
+
## Code
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// src/apps/main/tools/create-issue.tool.ts
|
|
23
|
+
import { PublicMcpError, Tool, ToolContext, z } from '@frontmcp/sdk';
|
|
24
|
+
import { randomUUID } from '@frontmcp/utils';
|
|
25
|
+
|
|
26
|
+
const inputSchema = {
|
|
27
|
+
repo: z.string().regex(/^[^\/]+\/[^\/]+$/, 'expected owner/repo'),
|
|
28
|
+
title: z.string().min(1).max(256),
|
|
29
|
+
body: z.string().max(65_536).optional(),
|
|
30
|
+
};
|
|
31
|
+
const outputSchema = { issueNumber: z.number().int().min(1), url: z.string().url() };
|
|
32
|
+
|
|
33
|
+
const MAX_ATTEMPTS = 4;
|
|
34
|
+
const BASE_DELAY_MS = 200;
|
|
35
|
+
|
|
36
|
+
@Tool({
|
|
37
|
+
name: 'create_issue',
|
|
38
|
+
description: 'Create a GitHub issue (retries on 5xx / 429)',
|
|
39
|
+
inputSchema,
|
|
40
|
+
outputSchema,
|
|
41
|
+
rateLimit: { maxRequests: 30, windowMs: 60_000 },
|
|
42
|
+
timeout: { executeMs: 30_000 }, // hard cap across all retries
|
|
43
|
+
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
44
|
+
authProviders: ['github'],
|
|
45
|
+
})
|
|
46
|
+
export class CreateIssueTool extends ToolContext {
|
|
47
|
+
async execute(input: { repo: string; title: string; body?: string }) {
|
|
48
|
+
const headers = await this.authProviders.headers('github');
|
|
49
|
+
const idempotencyKey = randomUUID();
|
|
50
|
+
|
|
51
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
52
|
+
const response = await this.fetch(`https://api.github.com/repos/${input.repo}/issues`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
...headers,
|
|
56
|
+
'content-type': 'application/json',
|
|
57
|
+
'idempotency-key': idempotencyKey,
|
|
58
|
+
},
|
|
59
|
+
body: JSON.stringify({ title: input.title, body: input.body }),
|
|
60
|
+
// `this.fetch` applies its own per-request timeout; the tool-level
|
|
61
|
+
// `timeout` below caps the whole retry loop at the framework level.
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (response.ok) {
|
|
65
|
+
const data = (await response.json()) as { number: number; html_url: string };
|
|
66
|
+
return { issueNumber: data.number, url: data.html_url };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Non-retryable client errors → fail immediately
|
|
70
|
+
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
|
|
71
|
+
const detail = await response.text();
|
|
72
|
+
this.fail(new PublicMcpError(`GitHub returned ${response.status}: ${detail.slice(0, 200)}`));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Retryable (5xx or 429) — back off and try again
|
|
76
|
+
if (attempt === MAX_ATTEMPTS) {
|
|
77
|
+
this.fail(new PublicMcpError(`GitHub upstream failed after ${MAX_ATTEMPTS} attempts`));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Retry-After is either a number of seconds ("120") or an HTTP-date — handle both.
|
|
81
|
+
const retryAfter = response.headers.get('retry-after');
|
|
82
|
+
const retryAfterMs = (() => {
|
|
83
|
+
if (!retryAfter) return undefined;
|
|
84
|
+
const seconds = Number(retryAfter);
|
|
85
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
|
|
86
|
+
const at = Date.parse(retryAfter);
|
|
87
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
|
|
88
|
+
})();
|
|
89
|
+
const baseDelay = retryAfterMs ?? BASE_DELAY_MS * 2 ** (attempt - 1);
|
|
90
|
+
const jitter = Math.floor(Math.random() * baseDelay * 0.2);
|
|
91
|
+
await this.notify(
|
|
92
|
+
`Attempt ${attempt} returned ${response.status}; retrying in ${baseDelay + jitter}ms`,
|
|
93
|
+
'warning',
|
|
94
|
+
);
|
|
95
|
+
await new Promise((r) => setTimeout(r, baseDelay + jitter));
|
|
96
|
+
}
|
|
97
|
+
/* unreachable — this.fail above never returns */
|
|
98
|
+
this.fail(new PublicMcpError('unreachable'));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## What This Demonstrates
|
|
104
|
+
|
|
105
|
+
- Retrying on transient errors (5xx + 429) with exponential backoff plus jitter
|
|
106
|
+
- Generating an `Idempotency-Key` so retried POSTs don't duplicate side effects on the upstream
|
|
107
|
+
- Respecting an upstream `Retry-After` header on 429 responses instead of guessing
|
|
108
|
+
- Capping the total retry budget with `timeout: { executeMs }` so a wedged upstream can't hang the call indefinitely
|
|
109
|
+
|
|
110
|
+
## Why these choices
|
|
111
|
+
|
|
112
|
+
- **`Idempotency-Key` is critical for POSTs.** Without it, a retry after a network glitch can create two issues. GitHub honors `Idempotency-Key` server-side; many other APIs (Stripe, etc.) do too.
|
|
113
|
+
- **Jitter prevents thundering herds.** All clients retrying at exactly 200ms / 400ms / 800ms create synchronized spikes. ±20% jitter spreads them.
|
|
114
|
+
- **`timeout: { executeMs: 30_000 }` is the safety net.** The retry loop alone can take 200 + 400 + 800 = 1.4s just in backoff. With a slow upstream, total time spirals — the tool-level timeout caps it.
|
|
115
|
+
- **Don't retry 4xx (except 429).** 4xx means "your request is wrong" — retrying won't help and may double up the upstream's accounting.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 13-tool-with-single-auth-provider
|
|
3
|
+
level: intermediate
|
|
4
|
+
description: "Tool requiring a single OAuth provider via the `authProviders: ['github']` string shorthand — credentials loaded before `execute()` runs."
|
|
5
|
+
tags: [auth-providers, oauth, github, this.authProviders]
|
|
6
|
+
features:
|
|
7
|
+
- "Declaring a single required OAuth provider with the `authProviders: ['github']` shorthand"
|
|
8
|
+
- "Reading pre-formatted credentials via `await this.authProviders.headers('github')`"
|
|
9
|
+
- "Letting the framework reject calls whose required credential is missing **before** `execute()` runs — a JSON-RPC `-32001` (MCP `UNAUTHORIZED`) error whose `data` carries `{ tool, providers: ['github'], authUrl }` (no auth-check boilerplate)"
|
|
10
|
+
- 'Trusting the framework to handle token refresh, expiration, and the connect/authorize URL'
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Tool With Single Auth Provider
|
|
14
|
+
|
|
15
|
+
Tool requiring a single OAuth provider via the `authProviders: ['github']` string shorthand — credentials loaded before `execute()` runs.
|
|
16
|
+
|
|
17
|
+
The shorthand form. By the time `execute()` runs, the user has completed the OAuth flow and credentials are in the vault — `this.authProviders.headers('github')` returns `{ Authorization: 'Bearer …' }` ready to forward.
|
|
18
|
+
|
|
19
|
+
## Code
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// src/apps/main/tools/list-repos.tool.ts
|
|
23
|
+
import { Tool, ToolContext, z } from '@frontmcp/sdk';
|
|
24
|
+
|
|
25
|
+
const inputSchema = {
|
|
26
|
+
visibility: z.enum(['all', 'public', 'private']).default('all'),
|
|
27
|
+
perPage: z.number().int().min(1).max(100).default(30),
|
|
28
|
+
};
|
|
29
|
+
const outputSchema = {
|
|
30
|
+
repos: z.array(z.object({ fullName: z.string(), stars: z.number().int(), private: z.boolean() })),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
@Tool({
|
|
34
|
+
name: 'list_repos',
|
|
35
|
+
description: 'List GitHub repos the authenticated user has access to',
|
|
36
|
+
inputSchema,
|
|
37
|
+
outputSchema,
|
|
38
|
+
authProviders: ['github'], // shorthand — single, required provider
|
|
39
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
40
|
+
})
|
|
41
|
+
export class ListReposTool extends ToolContext {
|
|
42
|
+
async execute(input: { visibility: 'all' | 'public' | 'private'; perPage: number }) {
|
|
43
|
+
const headers = await this.authProviders.headers('github');
|
|
44
|
+
|
|
45
|
+
const url = new URL('https://api.github.com/user/repos');
|
|
46
|
+
url.searchParams.set('visibility', input.visibility);
|
|
47
|
+
url.searchParams.set('per_page', String(input.perPage));
|
|
48
|
+
|
|
49
|
+
const response = await this.fetch(url, { headers });
|
|
50
|
+
const data = (await response.json()) as Array<{ full_name: string; stargazers_count: number; private: boolean }>;
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
repos: data.map((r) => ({
|
|
54
|
+
fullName: r.full_name,
|
|
55
|
+
stars: r.stargazers_count,
|
|
56
|
+
private: r.private,
|
|
57
|
+
})),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## What This Demonstrates
|
|
64
|
+
|
|
65
|
+
- Declaring a single required OAuth provider with the `authProviders: ['github']` shorthand
|
|
66
|
+
- Reading pre-formatted credentials via `await this.authProviders.headers('github')`
|
|
67
|
+
- Letting the framework reject calls whose required credential is missing **before** `execute()` runs — a JSON-RPC `-32001` (MCP `UNAUTHORIZED`) error whose `data` carries `{ tool, providers: ['github'], authUrl }` (no auth-check boilerplate)
|
|
68
|
+
- Trusting the framework to handle token refresh, expiration, and the connect/authorize URL
|
|
69
|
+
|
|
70
|
+
## What you don't have to write
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
// ❌ unnecessary — the framework already did all of this before execute() ran:
|
|
74
|
+
const cred = await this.authProviders.get('github');
|
|
75
|
+
if (!cred) {
|
|
76
|
+
this.fail(new PublicMcpError('No GitHub auth — please sign in', { authUrl: '…' }));
|
|
77
|
+
}
|
|
78
|
+
const accessToken = cred.credential.accessToken;
|
|
79
|
+
if (cred.expiresAt && Date.now() >= cred.expiresAt) {
|
|
80
|
+
/* refresh */
|
|
81
|
+
}
|
|
82
|
+
const headers = { Authorization: `Bearer ${accessToken}` };
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The single line `await this.authProviders.headers('github')` covers it all.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 14-tool-with-multiple-auth-providers
|
|
3
|
+
level: advanced
|
|
4
|
+
description: 'Tool with the full `authProviders` mapping form — one required provider with explicit scopes, one optional provider with an alias, and graceful degradation when the optional creds are missing.'
|
|
5
|
+
tags: [auth-providers, oauth, scopes, optional-auth, this.authProviders.headers]
|
|
6
|
+
features:
|
|
7
|
+
- 'Using the object form of `authProviders` to set `required`, `scopes`, and `alias`'
|
|
8
|
+
- Declaring required OAuth scopes that the server advertises in its Protected Resource Metadata (`scopes_supported`) so clients request them
|
|
9
|
+
- "Resolving an optional provider via `await this.authProviders.headers('cloud')` (returns an empty object `{}` when absent)"
|
|
10
|
+
- "Branching the tool's behavior — full deploy when both providers are present; preview-only when the cloud provider is missing"
|
|
11
|
+
- "The required `github` provider gating the call: when its credential is missing the framework aborts before `execute()` with `-32001` and `data: { tool, providers: ['github'], authUrl }`; the optional `aws`/`cloud` provider never gates"
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Tool With Multiple Auth Providers
|
|
15
|
+
|
|
16
|
+
Tool with the full `authProviders` mapping form — one required provider with explicit scopes, one optional provider with an alias, and graceful degradation when the optional creds are missing.
|
|
17
|
+
|
|
18
|
+
The full form unlocks scopes, optional providers, and aliases. Use it when the simple shorthand isn't enough.
|
|
19
|
+
|
|
20
|
+
## Code
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// src/apps/main/tools/deploy-app.tool.ts
|
|
24
|
+
import { Tool, ToolContext, z } from '@frontmcp/sdk';
|
|
25
|
+
|
|
26
|
+
const inputSchema = {
|
|
27
|
+
repo: z.string().regex(/^[^/]+\/[^/]+$/),
|
|
28
|
+
environment: z.enum(['staging', 'production']),
|
|
29
|
+
dryRun: z.boolean().default(false),
|
|
30
|
+
};
|
|
31
|
+
const outputSchema = {
|
|
32
|
+
deploymentId: z.string(),
|
|
33
|
+
url: z.string().url(),
|
|
34
|
+
mode: z.enum(['preview', 'deployed']),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
@Tool({
|
|
38
|
+
name: 'deploy_app',
|
|
39
|
+
description: 'Build and deploy a repo to cloud',
|
|
40
|
+
inputSchema,
|
|
41
|
+
outputSchema,
|
|
42
|
+
authProviders: [
|
|
43
|
+
{ name: 'github', required: true, scopes: ['repo', 'workflow'] }, // required + scoped
|
|
44
|
+
{ name: 'aws', required: false, alias: 'cloud' }, // optional, aliased
|
|
45
|
+
],
|
|
46
|
+
annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
47
|
+
})
|
|
48
|
+
export class DeployAppTool extends ToolContext {
|
|
49
|
+
async execute(input: { repo: string; environment: 'staging' | 'production'; dryRun: boolean }) {
|
|
50
|
+
const githubHeaders = await this.authProviders.headers('github');
|
|
51
|
+
const cloudHeaders = await this.authProviders.headers('cloud'); // {} when AWS not connected
|
|
52
|
+
const hasCloud = Object.keys(cloudHeaders).length > 0;
|
|
53
|
+
|
|
54
|
+
// 1. Build artifact from the repo (always works — we have GitHub creds)
|
|
55
|
+
const buildId = await this.triggerBuild(input.repo, githubHeaders);
|
|
56
|
+
|
|
57
|
+
// 2. Deploy — only if cloud creds are present
|
|
58
|
+
if (!hasCloud || input.dryRun) {
|
|
59
|
+
return {
|
|
60
|
+
deploymentId: `preview-${buildId}`,
|
|
61
|
+
url: `https://preview.example.com/${buildId}`,
|
|
62
|
+
mode: 'preview' as const,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const deploymentId = await this.deployToCloud(buildId, input.environment, cloudHeaders);
|
|
67
|
+
return {
|
|
68
|
+
deploymentId,
|
|
69
|
+
url: `https://${input.environment}.example.com/${deploymentId}`,
|
|
70
|
+
mode: 'deployed' as const,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async triggerBuild(_repo: string, _headers: Record<string, string>): Promise<string> {
|
|
75
|
+
return 'b_42';
|
|
76
|
+
}
|
|
77
|
+
private async deployToCloud(_buildId: string, _env: string, _headers: Record<string, string>): Promise<string> {
|
|
78
|
+
return 'd_99';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## What This Demonstrates
|
|
84
|
+
|
|
85
|
+
- Using the object form of `authProviders` to set `required`, `scopes`, and `alias`
|
|
86
|
+
- Declaring required OAuth scopes that the server advertises in its Protected Resource Metadata (`scopes_supported`) so clients request them
|
|
87
|
+
- Resolving an optional provider via `await this.authProviders.headers('cloud')` (returns an empty object `{}` when absent)
|
|
88
|
+
- Branching the tool's behavior — full deploy when both providers are present; preview-only when the cloud provider is missing
|
|
89
|
+
- The required `github` provider gating the call: when its credential is missing the framework aborts before `execute()` with `-32001` and `data: { tool, providers: ['github'], authUrl }`; the optional `aws`/`cloud` provider never gates
|
|
90
|
+
|
|
91
|
+
## Field reference (from auth-providers.md)
|
|
92
|
+
|
|
93
|
+
| Field | Default | Meaning |
|
|
94
|
+
| ---------- | -------- | -------------------------------------------------------------------------------------------- |
|
|
95
|
+
| `name` | — | Provider name — must match a registered credential provider |
|
|
96
|
+
| `required` | `true` | If `true`, the tool errors (`-32001`) before `execute()` runs when the credential is missing |
|
|
97
|
+
| `scopes` | — | OAuth scopes — advertised via PRM `scopes_supported` so clients know to request them |
|
|
98
|
+
| `alias` | = `name` | Local name for the provider — useful when two tools use the same provider differently |
|
|
99
|
+
|
|
100
|
+
## When to use the object form
|
|
101
|
+
|
|
102
|
+
- Need scopes (`required: true, scopes: ['repo']`) — must use the object form
|
|
103
|
+
- Need optional providers (`required: false`) — must use the object form
|
|
104
|
+
- Need to alias the provider name (rare) — must use the object form
|
|
105
|
+
- Just one always-required provider with default scopes → the `authProviders: ['github']` shorthand is shorter
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 15-tool-with-credential-vault
|
|
3
|
+
level: advanced
|
|
4
|
+
description: "Tool that reads a user-supplied static credential (a Slack webhook URL) from the per-session encrypted credential vault — the pattern for credentials that aren't OAuth."
|
|
5
|
+
tags: [auth-providers, credential-vault, slack-webhook, encryption-at-rest]
|
|
6
|
+
features:
|
|
7
|
+
- "Declaring a vault-backed auth provider with `authProviders: ['slack-webhook']`"
|
|
8
|
+
- "Reading the user's pasted-in credential via `await this.authProviders.headers('slack-webhook')` — same API as OAuth"
|
|
9
|
+
- Letting the framework handle per-session AES-256-GCM encryption at rest (Redis or memory store)
|
|
10
|
+
- Knowing when to pick the vault (static secrets the user knows) vs OAuth (delegated identity)
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Tool With Credential Vault
|
|
14
|
+
|
|
15
|
+
Tool that reads a user-supplied static credential (a Slack webhook URL) from the per-session encrypted credential vault — the pattern for credentials that aren't OAuth.
|
|
16
|
+
|
|
17
|
+
For credentials that aren't OAuth — webhook URLs, API keys the user pastes in, custom tokens — use a vault-backed auth provider. Same API as OAuth from the tool's perspective; the framework handles per-session encryption and key derivation.
|
|
18
|
+
|
|
19
|
+
## Code
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// src/apps/main/tools/send-to-slack.tool.ts
|
|
23
|
+
import { PublicMcpError, Tool, ToolContext, z } from '@frontmcp/sdk';
|
|
24
|
+
|
|
25
|
+
const inputSchema = {
|
|
26
|
+
channel: z.string().regex(/^#/).describe('Slack channel, e.g. #ops'),
|
|
27
|
+
text: z.string().min(1).max(4_000),
|
|
28
|
+
username: z.string().optional(),
|
|
29
|
+
iconEmoji: z.string().optional(),
|
|
30
|
+
};
|
|
31
|
+
const outputSchema = { sent: z.boolean(), channel: z.string() };
|
|
32
|
+
|
|
33
|
+
@Tool({
|
|
34
|
+
name: 'send_to_slack',
|
|
35
|
+
description: 'Send a message to a Slack channel via the user-supplied incoming-webhook URL',
|
|
36
|
+
inputSchema,
|
|
37
|
+
outputSchema,
|
|
38
|
+
authProviders: ['slack-webhook'], // vault-backed provider — see auth skill
|
|
39
|
+
annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
40
|
+
})
|
|
41
|
+
export class SendToSlackTool extends ToolContext {
|
|
42
|
+
async execute(input: { channel: string; text: string; username?: string; iconEmoji?: string }) {
|
|
43
|
+
const headers = await this.authProviders.headers('slack-webhook');
|
|
44
|
+
// `headers` is a plain `Record<string, string>` (NOT a `Headers` object), e.g.:
|
|
45
|
+
// { 'x-slack-webhook-url': 'https://hooks.slack.com/services/T.../B.../...' }
|
|
46
|
+
// Read values with string-index access (`headers['x-...']`), not `headers.get(...)`.
|
|
47
|
+
// The framework reads the value from the vault, decrypts with the per-session AES-256-GCM
|
|
48
|
+
// key, and never returns the raw value — it's only available indirectly via these headers.
|
|
49
|
+
|
|
50
|
+
// Reading the key directly is safe here because we declared `authProviders: ['slack-webhook']`
|
|
51
|
+
// (required: true by default), so the framework already rejected the call before `execute()`
|
|
52
|
+
// if the vault entry was missing — by the time we read the header, the provider is guaranteed
|
|
53
|
+
// to have produced it. If you ever switch the provider to `required: false`, `headers` will be
|
|
54
|
+
// an empty object `{}` when no credential is set, so guard with
|
|
55
|
+
// `if (!webhookUrl) this.fail(new PublicMcpError('No Slack webhook configured'))` instead.
|
|
56
|
+
const webhookUrl = headers['x-slack-webhook-url'];
|
|
57
|
+
const response = await this.fetch(webhookUrl, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'content-type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
channel: input.channel,
|
|
62
|
+
text: input.text,
|
|
63
|
+
username: input.username,
|
|
64
|
+
icon_emoji: input.iconEmoji,
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
this.fail(new PublicMcpError(`Slack webhook returned ${response.status}`));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { sent: true, channel: input.channel };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## What This Demonstrates
|
|
78
|
+
|
|
79
|
+
- Declaring a vault-backed auth provider with `authProviders: ['slack-webhook']`
|
|
80
|
+
- Reading the user's pasted-in credential via `await this.authProviders.headers('slack-webhook')` — same API as OAuth
|
|
81
|
+
- Letting the framework handle per-session AES-256-GCM encryption at rest (Redis or memory store)
|
|
82
|
+
- Knowing when to pick the vault (static secrets the user knows) vs OAuth (delegated identity)
|
|
83
|
+
|
|
84
|
+
## Vault vs OAuth — when to pick which
|
|
85
|
+
|
|
86
|
+
| Vault-backed | OAuth |
|
|
87
|
+
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
88
|
+
| User pastes in a webhook URL, API key, or token they own | User clicks "Connect to GitHub" — provider issues a token to your server |
|
|
89
|
+
| Static — doesn't refresh, doesn't expire | Refresh token + access token + expiration |
|
|
90
|
+
| Per-session — gone when the session ends (vs. Redis store, then it survives) | Long-lived — usually persists across sessions |
|
|
91
|
+
| For "bring your own" integrations (Slack webhooks, custom API keys) | For "log in with" integrations (GitHub, Google, Microsoft) |
|
|
92
|
+
|
|
93
|
+
## How vault-backed providers are configured
|
|
94
|
+
|
|
95
|
+
Server side, in the `auth` skill:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
@FrontMcp({
|
|
99
|
+
auth: {
|
|
100
|
+
providers: [
|
|
101
|
+
{
|
|
102
|
+
name: 'slack-webhook',
|
|
103
|
+
kind: 'vault',
|
|
104
|
+
fields: [
|
|
105
|
+
{ name: 'webhook-url', type: 'url', label: 'Slack incoming-webhook URL', required: true },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The framework renders the credential UI based on `fields`, encrypts the user's input, stores it per-session, and exposes it back to tools via `this.authProviders.headers('slack-webhook')`.
|
|
114
|
+
|
|
115
|
+
See the `auth` skill for full vault configuration, encryption-key management, Redis-backed storage, and the credential UI.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 16-tool-with-rate-limit
|
|
3
|
+
level: intermediate
|
|
4
|
+
description: 'Tool with `rateLimit: { maxRequests, windowMs }` capping invocations per session per minute — the protection for expensive / external-API-billed operations.'
|
|
5
|
+
tags: [throttling, rate-limit, abuse-protection]
|
|
6
|
+
features:
|
|
7
|
+
- "Capping the tool to N invocations per windowMs, partitioned per session via `partitionBy: 'session'`"
|
|
8
|
+
- "Letting the framework reject over-limit calls with `RateLimitError` (code `'RATE_LIMIT_EXCEEDED'`, HTTP status 429) carrying a retry-after hint in its message that clients can back off against"
|
|
9
|
+
- 'Combining `rateLimit` with `annotations.openWorldHint: true` so clients know the tool talks to billed external services'
|
|
10
|
+
- Sizing the limit against upstream quota / billing — not just "what feels reasonable"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Tool With Rate Limit
|
|
14
|
+
|
|
15
|
+
Tool with `rateLimit: { maxRequests, windowMs }` capping invocations per session per minute — the protection for expensive / external-API-billed operations.
|
|
16
|
+
|
|
17
|
+
The first throttle to reach for. Caps invocations over time. The default partition is `'global'` (one shared limit); this example sets `partitionBy: 'session'` so a runaway agent loop on one session can't burn through a quota that other sessions need.
|
|
18
|
+
|
|
19
|
+
## Code
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
// src/apps/main/tools/translate.tool.ts
|
|
23
|
+
import { Tool, ToolContext, z } from '@frontmcp/sdk';
|
|
24
|
+
|
|
25
|
+
const inputSchema = {
|
|
26
|
+
text: z.string().min(1).max(2_000),
|
|
27
|
+
targetLang: z.string().regex(/^[a-z]{2}$/, 'ISO 639-1 code'),
|
|
28
|
+
};
|
|
29
|
+
const outputSchema = { translated: z.string(), sourceLang: z.string() };
|
|
30
|
+
|
|
31
|
+
@Tool({
|
|
32
|
+
name: 'translate',
|
|
33
|
+
description: 'Translate text via the external translation API (billed per call)',
|
|
34
|
+
inputSchema,
|
|
35
|
+
outputSchema,
|
|
36
|
+
rateLimit: { maxRequests: 60, windowMs: 60_000, partitionBy: 'session' }, // 60 calls / minute / session
|
|
37
|
+
authProviders: ['translate-api'],
|
|
38
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
39
|
+
})
|
|
40
|
+
export class TranslateTool extends ToolContext {
|
|
41
|
+
async execute(input: { text: string; targetLang: string }) {
|
|
42
|
+
const headers = await this.authProviders.headers('translate-api');
|
|
43
|
+
const response = await this.fetch('https://api.translate.example/v1/translate', {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ text: input.text, target: input.targetLang }),
|
|
47
|
+
});
|
|
48
|
+
const data = (await response.json()) as { translated: string; detectedSource: string };
|
|
49
|
+
return { translated: data.translated, sourceLang: data.detectedSource };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
> **Testing.** The framework throws `RateLimitError` (HTTP 429, code `'RATE_LIMIT_EXCEEDED'`) for over-limit calls. Tests that assert the rate-limit fires after N calls live in the dedicated `testing` skill — the canonical pattern uses `@frontmcp/testing`'s `TestServer` + Playwright `test`/`expect` fixtures.
|
|
55
|
+
|
|
56
|
+
## What This Demonstrates
|
|
57
|
+
|
|
58
|
+
- Capping the tool to N invocations per windowMs, partitioned per session via `partitionBy: 'session'`
|
|
59
|
+
- Letting the framework reject over-limit calls with `RateLimitError` (code `'RATE_LIMIT_EXCEEDED'`, HTTP status 429) carrying a retry-after hint in its message that clients can back off against
|
|
60
|
+
- Combining `rateLimit` with `annotations.openWorldHint: true` so clients know the tool talks to billed external services
|
|
61
|
+
- Sizing the limit against upstream quota / billing — not just "what feels reasonable"
|
|
62
|
+
|
|
63
|
+
## Sizing the limit
|
|
64
|
+
|
|
65
|
+
- **External API quotas** — set well below the upstream quota. If GitHub allows 5,000/hour authenticated, a per-session limit of 30/min (1,800/hr) gives room for multiple sessions to share the budget.
|
|
66
|
+
- **Billed services** — lower. A `translate` call may cost $0.02. 60/min × 60min × 24h × 1 session = $1,728/day worst case.
|
|
67
|
+
- **Pure rate concern, not cost** — 100/min is generous for almost anything.
|
|
68
|
+
|
|
69
|
+
## Global vs per-session
|
|
70
|
+
|
|
71
|
+
The default partition is `'global'` — one limit shared across all callers. For a limit that's tracked independently per session, set `partitionBy: 'session'` (so a runaway loop on one session can't burn through everyone's budget). See the `throttling` reference for the full set of `partitionBy` strategies (`'global' | 'session' | 'userId' | 'ip'` or a custom function).
|