fa-mcp-sdk 0.4.76 → 0.4.79
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 +319 -314
- package/bin/fa-mcp.js +85 -68
- package/cli-template/.claude/agents/javascript-pro.md +276 -276
- package/cli-template/.claude/settings.json +50 -50
- package/cli-template/.claude/skills/upgrade-guide/SKILL.md +2 -1
- package/cli-template/.oxfmtrc.json +41 -0
- package/cli-template/.oxlintrc.json +120 -0
- package/cli-template/CLAUDE.md +358 -355
- package/cli-template/FA-MCP-SDK-DOC/00-FA-MCP-SDK-index.md +132 -132
- package/cli-template/FA-MCP-SDK-DOC/01-getting-started.md +146 -146
- package/cli-template/FA-MCP-SDK-DOC/02-1-tools-and-api.md +431 -431
- package/cli-template/FA-MCP-SDK-DOC/02-2-prompts-and-resources.md +201 -201
- package/cli-template/FA-MCP-SDK-DOC/03-configuration.md +384 -384
- package/cli-template/FA-MCP-SDK-DOC/04-authentication.md +412 -412
- package/cli-template/FA-MCP-SDK-DOC/05-ad-authorization.md +196 -196
- package/cli-template/FA-MCP-SDK-DOC/06-utilities.md +163 -163
- package/cli-template/FA-MCP-SDK-DOC/07-testing-and-operations.md +127 -127
- package/cli-template/jest.config.js +27 -30
- package/cli-template/package.json +10 -5
- package/cli-template/prompt-example-new-MCP.md +101 -101
- package/cli-template/readme-docs/SKILLS.md +1 -1
- package/cli-template/tsconfig.json +58 -58
- package/cli-template/update.cjs +41 -38
- package/config/custom-environment-variables.yaml +63 -63
- package/config/development.yaml +4 -4
- package/config/production.yaml +4 -4
- package/config/test.yaml +26 -26
- package/dist/core/_types_/TNtlm.d.ts.map +1 -1
- package/dist/core/_types_/active-directory-config.d.ts.map +1 -1
- package/dist/core/_types_/config.d.ts.map +1 -1
- package/dist/core/_types_/types.d.ts.map +1 -1
- package/dist/core/ad/group-checker.d.ts.map +1 -1
- package/dist/core/ad/group-checker.js.map +1 -1
- package/dist/core/agent-tester/agent-tester-router.d.ts.map +1 -1
- package/dist/core/agent-tester/agent-tester-router.js +6 -6
- package/dist/core/agent-tester/agent-tester-router.js.map +1 -1
- package/dist/core/agent-tester/check-llm.d.ts.map +1 -1
- package/dist/core/agent-tester/check-llm.js.map +1 -1
- package/dist/core/agent-tester/services/SummaryMemory.d.ts.map +1 -1
- package/dist/core/agent-tester/services/SummaryMemory.js +3 -9
- package/dist/core/agent-tester/services/SummaryMemory.js.map +1 -1
- package/dist/core/agent-tester/services/TesterAgentService.d.ts.map +1 -1
- package/dist/core/agent-tester/services/TesterAgentService.js +25 -27
- package/dist/core/agent-tester/services/TesterAgentService.js.map +1 -1
- package/dist/core/agent-tester/services/TesterMcpClientService.d.ts.map +1 -1
- package/dist/core/agent-tester/services/TesterMcpClientService.js +26 -25
- package/dist/core/agent-tester/services/TesterMcpClientService.js.map +1 -1
- package/dist/core/auth/admin-auth.d.ts.map +1 -1
- package/dist/core/auth/admin-auth.js +5 -5
- package/dist/core/auth/admin-auth.js.map +1 -1
- package/dist/core/auth/agent-tester-auth.d.ts.map +1 -1
- package/dist/core/auth/agent-tester-auth.js +1 -6
- package/dist/core/auth/agent-tester-auth.js.map +1 -1
- package/dist/core/auth/basic.d.ts.map +1 -1
- package/dist/core/auth/basic.js.map +1 -1
- package/dist/core/auth/ip-check.d.ts.map +1 -1
- package/dist/core/auth/ip-check.js +1 -1
- package/dist/core/auth/ip-check.js.map +1 -1
- package/dist/core/auth/jwt.d.ts.map +1 -1
- package/dist/core/auth/jwt.js +1 -1
- package/dist/core/auth/jwt.js.map +1 -1
- package/dist/core/auth/middleware.d.ts.map +1 -1
- package/dist/core/auth/middleware.js +9 -6
- package/dist/core/auth/middleware.js.map +1 -1
- package/dist/core/auth/multi-auth.d.ts.map +1 -1
- package/dist/core/auth/multi-auth.js +6 -6
- package/dist/core/auth/multi-auth.js.map +1 -1
- package/dist/core/auth/revocation.d.ts.map +1 -1
- package/dist/core/auth/revocation.js +2 -6
- package/dist/core/auth/revocation.js.map +1 -1
- package/dist/core/auth/token-generator/ntlm/ntlm-auth-options.d.ts.map +1 -1
- package/dist/core/auth/token-generator/ntlm/ntlm-auth-options.js +2 -2
- package/dist/core/auth/token-generator/ntlm/ntlm-auth-options.js.map +1 -1
- package/dist/core/auth/token-generator/ntlm/ntlm-domain-config.js +1 -1
- package/dist/core/auth/token-generator/ntlm/ntlm-domain-config.js.map +1 -1
- package/dist/core/auth/token-generator/ntlm/ntlm-integration.d.ts.map +1 -1
- package/dist/core/auth/token-generator/ntlm/ntlm-integration.js +4 -2
- package/dist/core/auth/token-generator/ntlm/ntlm-integration.js.map +1 -1
- package/dist/core/auth/token-generator/server.d.ts.map +1 -1
- package/dist/core/auth/token-generator/server.js.map +1 -1
- package/dist/core/bootstrap/init-config.d.ts.map +1 -1
- package/dist/core/bootstrap/init-config.js +2 -2
- package/dist/core/bootstrap/init-config.js.map +1 -1
- package/dist/core/bootstrap/startup-info.d.ts.map +1 -1
- package/dist/core/bootstrap/startup-info.js +3 -7
- package/dist/core/bootstrap/startup-info.js.map +1 -1
- package/dist/core/cache/cache.d.ts.map +1 -1
- package/dist/core/cache/cache.js +2 -2
- package/dist/core/cache/cache.js.map +1 -1
- package/dist/core/consul/deregister.d.ts.map +1 -1
- package/dist/core/consul/deregister.js.map +1 -1
- package/dist/core/consul/get-consul-api.d.ts.map +1 -1
- package/dist/core/consul/get-consul-api.js +1 -2
- package/dist/core/consul/get-consul-api.js.map +1 -1
- package/dist/core/db/pg-db.d.ts.map +1 -1
- package/dist/core/db/pg-db.js +3 -3
- package/dist/core/db/pg-db.js.map +1 -1
- package/dist/core/debug.d.ts.map +1 -1
- package/dist/core/debug.js.map +1 -1
- package/dist/core/errors/BaseMcpError.d.ts.map +1 -1
- package/dist/core/errors/BaseMcpError.js.map +1 -1
- package/dist/core/errors/ValidationError.d.ts.map +1 -1
- package/dist/core/errors/ValidationError.js.map +1 -1
- package/dist/core/errors/errors.d.ts.map +1 -1
- package/dist/core/errors/errors.js +1 -1
- package/dist/core/errors/errors.js.map +1 -1
- package/dist/core/index.d.ts +6 -6
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +5 -5
- package/dist/core/index.js.map +1 -1
- package/dist/core/init-mcp-server.d.ts.map +1 -1
- package/dist/core/init-mcp-server.js.map +1 -1
- package/dist/core/logger.d.ts.map +1 -1
- package/dist/core/logger.js +1 -1
- package/dist/core/logger.js.map +1 -1
- package/dist/core/mcp/create-mcp-server.d.ts.map +1 -1
- package/dist/core/mcp/create-mcp-server.js +1 -1
- package/dist/core/mcp/create-mcp-server.js.map +1 -1
- package/dist/core/mcp/prompts.d.ts.map +1 -1
- package/dist/core/mcp/prompts.js.map +1 -1
- package/dist/core/mcp/readme-assembler.d.ts.map +1 -1
- package/dist/core/mcp/readme-assembler.js +3 -1
- package/dist/core/mcp/readme-assembler.js.map +1 -1
- package/dist/core/mcp/resources.d.ts.map +1 -1
- package/dist/core/mcp/resources.js.map +1 -1
- package/dist/core/mcp/server-stdio.d.ts.map +1 -1
- package/dist/core/utils/formatToolResult.d.ts.map +1 -1
- package/dist/core/utils/formatToolResult.js.map +1 -1
- package/dist/core/utils/port-checker.d.ts.map +1 -1
- package/dist/core/utils/port-checker.js.map +1 -1
- package/dist/core/utils/rate-limit.d.ts.map +1 -1
- package/dist/core/utils/rate-limit.js +2 -8
- package/dist/core/utils/rate-limit.js.map +1 -1
- package/dist/core/utils/testing/BaseMcpClient.d.ts.map +1 -1
- package/dist/core/utils/testing/BaseMcpClient.js.map +1 -1
- package/dist/core/utils/testing/McpHttpClient.d.ts.map +1 -1
- package/dist/core/utils/testing/McpHttpClient.js +2 -2
- package/dist/core/utils/testing/McpHttpClient.js.map +1 -1
- package/dist/core/utils/testing/McpSseClient.d.ts.map +1 -1
- package/dist/core/utils/testing/McpSseClient.js +3 -8
- package/dist/core/utils/testing/McpSseClient.js.map +1 -1
- package/dist/core/utils/testing/McpStdioClient.d.ts.map +1 -1
- package/dist/core/utils/testing/McpStdioClient.js.map +1 -1
- package/dist/core/utils/testing/McpStreamableHttpClient.d.ts.map +1 -1
- package/dist/core/utils/testing/McpStreamableHttpClient.js +7 -8
- package/dist/core/utils/testing/McpStreamableHttpClient.js.map +1 -1
- package/dist/core/utils/utils.d.ts.map +1 -1
- package/dist/core/utils/utils.js +3 -5
- package/dist/core/utils/utils.js.map +1 -1
- package/dist/core/web/admin-router.d.ts.map +1 -1
- package/dist/core/web/admin-router.js +3 -3
- package/dist/core/web/admin-router.js.map +1 -1
- package/dist/core/web/cors.d.ts.map +1 -1
- package/dist/core/web/cors.js.map +1 -1
- package/dist/core/web/favicon-svg.d.ts.map +1 -1
- package/dist/core/web/favicon-svg.js +1 -5
- package/dist/core/web/favicon-svg.js.map +1 -1
- package/dist/core/web/home-api.d.ts.map +1 -1
- package/dist/core/web/home-api.js +7 -8
- package/dist/core/web/home-api.js.map +1 -1
- package/dist/core/web/openapi.d.ts.map +1 -1
- package/dist/core/web/openapi.js +1 -3
- package/dist/core/web/openapi.js.map +1 -1
- package/dist/core/web/server-http.d.ts.map +1 -1
- package/dist/core/web/server-http.js +4 -4
- package/dist/core/web/server-http.js.map +1 -1
- package/dist/core/web/static/agent-tester/index.html +323 -323
- package/dist/core/web/static/agent-tester/script.js +311 -200
- package/dist/core/web/static/agent-tester/styles.css +1840 -1840
- package/dist/core/web/static/home/index.html +220 -220
- package/dist/core/web/static/home/script.js +72 -43
- package/dist/core/web/static/styles.css +927 -927
- package/dist/core/web/static/token-gen/index.html +136 -136
- package/dist/core/web/static/token-gen/script.js +58 -56
- package/dist/core/web/svg-icons.d.ts.map +1 -1
- package/dist/core/web/svg-icons.js +1 -5
- package/dist/core/web/svg-icons.js.map +1 -1
- package/package.json +10 -5
- package/{cli-template/.claude/hooks/eslint-fix.cjs → scripts/cc-hook-oxlint-oxfmt-fix.cjs} +109 -100
- package/scripts/generate-jwt.js +5 -9
- package/scripts/kill-port.js +5 -2
- package/scripts/npm/run.js +1 -2
- package/scripts/remove-nul.js +1 -1
- package/scripts/update-sdk.js +36 -14
- package/src/template/api/router.ts +3 -3
- package/src/template/prompts/agent-brief.ts +0 -1
- package/src/template/start.ts +3 -8
- package/src/template/tools/handle-tool-call.ts +3 -3
- package/src/template/tools/tools.ts +3 -7
- package/src/tests/jest-simple-reporter.js +1 -1
- package/src/tests/mcp/sse/mcp-sse-client-handling.md +111 -111
- package/src/tests/mcp/sse/test-sse-npm-package.js +2 -3
- package/src/tests/mcp/test-cases.js +6 -7
- package/src/tests/mcp/test-http.js +2 -2
- package/src/tests/mcp/test-sse.js +9 -7
- package/src/tests/mcp/test-stdio.js +12 -8
- package/src/tests/utils.ts +4 -3
- package/cli-template/eslint.config.js +0 -27
|
@@ -1,431 +1,431 @@
|
|
|
1
|
-
# Tools and REST API
|
|
2
|
-
|
|
3
|
-
## Tool Development
|
|
4
|
-
|
|
5
|
-
### Tool Definition (`src/tools/tools.ts`)
|
|
6
|
-
|
|
7
|
-
```typescript
|
|
8
|
-
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
-
|
|
10
|
-
export const tools: Tool[] = [{
|
|
11
|
-
name: 'my_custom_tool',
|
|
12
|
-
description: 'Description of what this tool does',
|
|
13
|
-
inputSchema: {
|
|
14
|
-
type: 'object',
|
|
15
|
-
properties: {
|
|
16
|
-
query: { type: 'string', description: 'Input query' },
|
|
17
|
-
options: { type: 'object', description: 'Optional config' },
|
|
18
|
-
},
|
|
19
|
-
required: ['query'],
|
|
20
|
-
},
|
|
21
|
-
}];
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
### Tool Handler (`src/tools/handle-tool-call.ts`)
|
|
25
|
-
|
|
26
|
-
```typescript
|
|
27
|
-
import { formatToolResult, ToolExecutionError, logger, IToolHandlerParams } from 'fa-mcp-sdk';
|
|
28
|
-
|
|
29
|
-
export const handleToolCall = async (params: IToolHandlerParams): Promise<any> => {
|
|
30
|
-
const { name, arguments: args, headers, payload, transport } = params;
|
|
31
|
-
// payload: { user: string, ... } if JWT auth enabled
|
|
32
|
-
// transport: 'stdio' | 'sse' | 'http'
|
|
33
|
-
// headers: normalized lowercase keys
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
switch (name) {
|
|
37
|
-
case 'my_custom_tool':
|
|
38
|
-
if (!args?.query) throw new ToolExecutionError(name, 'Query required');
|
|
39
|
-
return formatToolResult({ message: `Processed: ${args.query}` });
|
|
40
|
-
default:
|
|
41
|
-
throw new ToolExecutionError(name, `Unknown tool: ${name}`);
|
|
42
|
-
}
|
|
43
|
-
} catch (error) {
|
|
44
|
-
logger.error(`Tool ${name} failed:`, error);
|
|
45
|
-
throw error;
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
### Headers Access
|
|
51
|
-
|
|
52
|
-
Headers are normalized to lowercase. Available in HTTP/SSE transports:
|
|
53
|
-
|
|
54
|
-
```typescript
|
|
55
|
-
const authHeader = headers?.authorization;
|
|
56
|
-
const userAgent = headers?.['user-agent'];
|
|
57
|
-
const clientIP = headers?.['x-real-ip'] || headers?.['x-forwarded-for'];
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
### Transport-Based Credentials
|
|
61
|
-
|
|
62
|
-
`IToolHandlerParams` includes `ITransportContext` fields (`transport`, `headers`, `payload`).
|
|
63
|
-
See [ITransportContext](./02-2-prompts-and-resources.md#itransportcontext).
|
|
64
|
-
|
|
65
|
-
### Outbound Webhooks (`x-web-hook`)
|
|
66
|
-
|
|
67
|
-
Handler-level pattern. The SDK does **not** ship a built-in webhook dispatcher — it exposes
|
|
68
|
-
everything you need (`params.headers`, `appConfig`, `logger`) and leaves the policy to the project.
|
|
69
|
-
This section is the **canonical recipe**: implement it as written so every fa-mcp-sdk-based MCP
|
|
70
|
-
server behaves the same way for clients and downstream collectors.
|
|
71
|
-
|
|
72
|
-
**What it is:** after every tool invocation the server can `POST` the tool result to an external
|
|
73
|
-
URL. Useful for audit trails, real-time dashboards, chaining MCP calls into CI/automation pipelines.
|
|
74
|
-
Opt-in per request (via header) and optionally per tool (via the response object). A failing webhook
|
|
75
|
-
**must never** fail the tool call.
|
|
76
|
-
|
|
77
|
-
#### Contract (stable across all MCPs)
|
|
78
|
-
|
|
79
|
-
**Inbound — precedence:**
|
|
80
|
-
|
|
81
|
-
| Source | Form | Precedence |
|
|
82
|
-
|---------------------|---------------------------------------------------------|------------|
|
|
83
|
-
| Per-tool override | `IToolResponse.hook: string` returned by the handler | wins |
|
|
84
|
-
| Per-request header | `x-web-hook: <http(s) URL>` | fallback |
|
|
85
|
-
|
|
86
|
-
If neither is present, no webhook fires.
|
|
87
|
-
|
|
88
|
-
**Outbound request:**
|
|
89
|
-
|
|
90
|
-
- Method: `POST`, `Content-Type: application/json`, timeout ≤ 10 000 ms
|
|
91
|
-
- Body:
|
|
92
|
-
|
|
93
|
-
```json
|
|
94
|
-
{
|
|
95
|
-
"mcpName": "<appConfig.name>",
|
|
96
|
-
"tool": "<tool_name>",
|
|
97
|
-
"user": "<caller-id-or-omitted>",
|
|
98
|
-
"response": { "...": "tool's full JSON result" }
|
|
99
|
-
}
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
| Field | Description |
|
|
103
|
-
|------------|------------------------------------------------------------------------------|
|
|
104
|
-
| `mcpName` | `appConfig.name` — identifies which MCP sent the callback |
|
|
105
|
-
| `tool` | Name of the invoked tool |
|
|
106
|
-
| `user` | Best-effort caller identity (see *User resolution*); **omit** if unresolved |
|
|
107
|
-
| `response` | Full JSON returned by the tool handler (same payload sent to the client) |
|
|
108
|
-
|
|
109
|
-
Do **not** add ad-hoc fields on a per-project basis without versioning the body — downstream
|
|
110
|
-
collectors rely on this exact shape.
|
|
111
|
-
|
|
112
|
-
#### Implementation recipe
|
|
113
|
-
|
|
114
|
-
**1. Declare the header** so `use://http-headers`, Agent Tester, and tool-call introspection
|
|
115
|
-
advertise it:
|
|
116
|
-
|
|
117
|
-
```typescript
|
|
118
|
-
// src/start.ts
|
|
119
|
-
usedHttpHeaders.push({
|
|
120
|
-
name: 'x-web-hook',
|
|
121
|
-
description:
|
|
122
|
-
'Optional URL called via POST after each tool invocation. '
|
|
123
|
-
+ 'Body: { mcpName, tool, user, response }. Fire-and-forget; failures are logged only.',
|
|
124
|
-
isOptional: true,
|
|
125
|
-
});
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
**2. Add `hook?` to the internal tool-response type** (lets a handler override the URL per tool):
|
|
129
|
-
|
|
130
|
-
```typescript
|
|
131
|
-
// src/_types_/tool.ts
|
|
132
|
-
export interface IToolResponse {
|
|
133
|
-
text: string;
|
|
134
|
-
json: Record<string, any>;
|
|
135
|
-
hook?: string; // per-tool URL override; takes precedence over x-web-hook header
|
|
136
|
-
}
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
**3. Dispatcher — fire-and-forget, never throws:**
|
|
140
|
-
|
|
141
|
-
```typescript
|
|
142
|
-
// src/tools/tools-manager.ts
|
|
143
|
-
import axios from 'axios';
|
|
144
|
-
import { appConfig, logger as lgr, toStr } from 'fa-mcp-sdk';
|
|
145
|
-
|
|
146
|
-
const logger = lgr.getSubLogger({ name: 'tools' });
|
|
147
|
-
const URL_REGEX = /^https?:\/\/[^\s]+$/i;
|
|
148
|
-
|
|
149
|
-
const callWebHook = (
|
|
150
|
-
url: string,
|
|
151
|
-
toolName: string,
|
|
152
|
-
json: Record<string, any>,
|
|
153
|
-
user?: string,
|
|
154
|
-
): void => {
|
|
155
|
-
if (!URL_REGEX.test(url)) { return; } // silently drop garbage URLs
|
|
156
|
-
const body = { mcpName: appConfig.name, tool: toolName, response: json, user };
|
|
157
|
-
axios.post(url, body, { timeout: 10_000 })
|
|
158
|
-
.catch((err) => logger.warn(`Web-hook POST ${url} failed: ${toStr(err?.message || err)}`));
|
|
159
|
-
};
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
Rules:
|
|
163
|
-
|
|
164
|
-
- **No `await`.** The webhook must not delay the MCP response.
|
|
165
|
-
- **No re-throws.** A 5xx, timeout, or DNS failure is a `warn` log, nothing more.
|
|
166
|
-
- **URL allow-list.** At minimum, require `http(s)://`. Add an internal-net allow-list via config
|
|
167
|
-
(e.g. `webhook.allowedHosts`) if the threat model requires it (see *Security*).
|
|
168
|
-
|
|
169
|
-
**4. Wire it into the tool-call entry point** — dispatch after the handler resolves and before
|
|
170
|
-
the result is returned:
|
|
171
|
-
|
|
172
|
-
```typescript
|
|
173
|
-
export const handleToolCall = async (params: IToolHandlerParams): Promise<any> => {
|
|
174
|
-
const { name: toolName, arguments: args, headers: mcpRequestHeaders = {} } = params;
|
|
175
|
-
|
|
176
|
-
const tool = (await getTools(mcpRequestHeaders)).get(toolName);
|
|
177
|
-
if (!tool?.handler) { throw new ToolExecutionError(toolName, `Unknown tool: ${toolName}`); }
|
|
178
|
-
|
|
179
|
-
const ctx: ToolContext = {
|
|
180
|
-
httpClient: createHttpClient(mcpRequestHeaders),
|
|
181
|
-
logger: logger.getSubLogger({ name: toolName }),
|
|
182
|
-
mcpRequestHeaders,
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
const toolResponse: IToolResponse = await tool.handler(args, ctx);
|
|
186
|
-
|
|
187
|
-
// ─── webhook dispatch (fire-and-forget) ─────────────────────────────────────
|
|
188
|
-
const hookUrl = (toolResponse?.hook || mcpRequestHeaders['x-web-hook'] || '').trim();
|
|
189
|
-
if (hookUrl) {
|
|
190
|
-
const syncUser = resolveActualUser(mcpRequestHeaders); // see step 5
|
|
191
|
-
if (syncUser) {
|
|
192
|
-
callWebHook(hookUrl, toolName, toolResponse.json, syncUser);
|
|
193
|
-
} else {
|
|
194
|
-
// Async user resolution — still fire-and-forget; do not block the tool response.
|
|
195
|
-
getCachedSelfUser(ctx.httpClient, mcpRequestHeaders)
|
|
196
|
-
.then((u) => callWebHook(hookUrl, toolName, toolResponse.json, u));
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
200
|
-
|
|
201
|
-
return formatToolResult(toolResponse);
|
|
202
|
-
};
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
**5. User resolution — best-effort, two-step.** The `user` field is what makes the webhook useful
|
|
206
|
-
for audit. Resolve carefully, but never let resolution fail the call.
|
|
207
|
-
|
|
208
|
-
- **Step A — Sync (preferred):** derive from headers / JWT payload / config without I/O
|
|
209
|
-
(e.g. JWT `payload.user`, a custom `x-actual-user` header your auth layer stamps, etc.).
|
|
210
|
-
- **Step B — Async fallback (only when sync returns nothing):** call the upstream "who am I"
|
|
211
|
-
endpoint with the same auth, **cache the result** (recommended TTL: 1 h, key by hashed
|
|
212
|
-
`Authorization`), and dedupe in-flight requests (thundering-herd protection).
|
|
213
|
-
- If both steps fail → **omit** the `user` field. Never invent a placeholder like `"unknown"`.
|
|
214
|
-
|
|
215
|
-
```typescript
|
|
216
|
-
export function resolveActualUser (headers: Record<string, string>): string | undefined { /* … */ }
|
|
217
|
-
|
|
218
|
-
export const getCachedSelfUser = async (
|
|
219
|
-
httpClient: AxiosInstance,
|
|
220
|
-
headers: Record<string, string>,
|
|
221
|
-
): Promise<string | undefined> => { /* GET /me, cache by hashed Authorization, dedupe */ };
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
#### Per-tool override — when to use
|
|
225
|
-
|
|
226
|
-
A handler may force a specific webhook URL:
|
|
227
|
-
|
|
228
|
-
```typescript
|
|
229
|
-
return { text, json, hook: 'https://collector.internal/special' };
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
Use sparingly. Legitimate cases:
|
|
233
|
-
|
|
234
|
-
- a long-running tool whose result feeds a fixed pipeline regardless of the client;
|
|
235
|
-
- a tool that should **never** webhook (e.g. read of a secret) — return `hook: ''` only if the
|
|
236
|
-
dispatcher treats empty string as "skip even if header is set". With the snippet above this works
|
|
237
|
-
naturally because `(toolResponse?.hook || header)` short-circuits on any truthy `hook`; to force
|
|
238
|
-
skip, have the handler strip the header from `ctx` or short-circuit `hookUrl` explicitly.
|
|
239
|
-
|
|
240
|
-
If neither applies, do not set `hook` — let the client decide.
|
|
241
|
-
|
|
242
|
-
#### Security
|
|
243
|
-
|
|
244
|
-
- **URL validation** — reject anything that does not match `http(s)://…`. For public-facing MCPs,
|
|
245
|
-
restrict to a configured allow-list (`webhook.allowedHosts` in `config/default.yaml`).
|
|
246
|
-
- **SSRF surface** — the webhook is a server-side `POST` to a client-supplied URL. Acceptable for
|
|
247
|
-
trusted MCP clients; not acceptable open on the internet without an allow-list.
|
|
248
|
-
- **No secrets in the body** — `response` is the same JSON the client already received. Do **not**
|
|
249
|
-
add credentials, raw tokens, or PII not present in the response.
|
|
250
|
-
- **No retries** — duplicate POSTs to a flaky collector are worse than a missed event. If the
|
|
251
|
-
collector needs guarantees, let it poll.
|
|
252
|
-
- **Logging** — log `tool`, target host, and outcome at `warn`/`debug`; **never** log the full body
|
|
253
|
-
at `info` level (audit log noise + potential PII).
|
|
254
|
-
|
|
255
|
-
#### Testing checklist
|
|
256
|
-
|
|
257
|
-
- [ ] Header declared in `usedHttpHeaders` and visible at `/use://http-headers`.
|
|
258
|
-
- [ ] Tool call **without** `x-web-hook` → no outbound POST.
|
|
259
|
-
- [ ] Tool call **with** valid `x-web-hook` → exactly one POST, body matches the contract above.
|
|
260
|
-
- [ ] Collector returns 500 → tool response still succeeds; one `warn` line in the log.
|
|
261
|
-
- [ ] Collector hangs → tool response returns within normal latency; POST aborts at 10 s.
|
|
262
|
-
- [ ] Malformed URL (`javascript:…`, missing scheme) → no POST, no error to client.
|
|
263
|
-
- [ ] Per-tool `hook` set → wins over the header.
|
|
264
|
-
- [ ] Sync user resolution hits → `user` populated immediately, no extra HTTP call.
|
|
265
|
-
- [ ] Sync empty, async succeeds → POST fires after `/me` resolves; tool response was not delayed.
|
|
266
|
-
- [ ] Both user paths fail → POST fires with `user` **field omitted** (not `null`, not `"unknown"`).
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
## REST API Endpoints
|
|
270
|
-
|
|
271
|
-
Define REST endpoints in `src/api/router.ts` using [tsoa](https://tsoa-community.github.io/docs/) decorators.
|
|
272
|
-
|
|
273
|
-
### OpenAPI Generation
|
|
274
|
-
|
|
275
|
-
- **Auto-generated** on startup if `swagger/openapi.yaml` missing
|
|
276
|
-
- **Swagger UI**: `/docs`
|
|
277
|
-
- **Spec**: `/api/openapi.json`, `/api/openapi.yaml`
|
|
278
|
-
- Regenerate: delete `swagger/openapi.yaml` and restart
|
|
279
|
-
|
|
280
|
-
### Controller Example
|
|
281
|
-
|
|
282
|
-
```typescript
|
|
283
|
-
import { Router } from 'express';
|
|
284
|
-
import { Route, Get, Post, Body, Tags, Query } from 'tsoa';
|
|
285
|
-
import { logger } from 'fa-mcp-sdk';
|
|
286
|
-
|
|
287
|
-
export const apiRouter: Router = Router();
|
|
288
|
-
|
|
289
|
-
interface UserResponse { id: string; name: string; email: string; }
|
|
290
|
-
interface CreateUserRequest { name: string; email: string; }
|
|
291
|
-
|
|
292
|
-
@Route('api')
|
|
293
|
-
export class UserController {
|
|
294
|
-
@Get('users/{userId}')
|
|
295
|
-
@Tags('Users')
|
|
296
|
-
public async getUser(userId: string): Promise<UserResponse> {
|
|
297
|
-
return { id: userId, name: 'John', email: 'john@example.com' };
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
@Post('users')
|
|
301
|
-
@Tags('Users')
|
|
302
|
-
public async createUser(@Body() body: CreateUserRequest): Promise<UserResponse> {
|
|
303
|
-
return { id: 'new-id', name: body.name, email: body.email };
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
@Get('users')
|
|
307
|
-
@Tags('Users')
|
|
308
|
-
public async searchUsers(@Query() query?: string, @Query() limit?: number): Promise<UserResponse[]> {
|
|
309
|
-
return [];
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
### tsoa Decorators
|
|
315
|
-
|
|
316
|
-
| Decorator | Example |
|
|
317
|
-
|-----------|---------|
|
|
318
|
-
| `@Route('prefix')` | `@Route('api')` |
|
|
319
|
-
| `@Get('path')` | `@Get('users/{id}')` |
|
|
320
|
-
| `@Post('path')` | `@Post('users')` |
|
|
321
|
-
| `@Put('path')` | `@Put('users/{id}')` |
|
|
322
|
-
| `@Delete('path')` | `@Delete('users/{id}')` |
|
|
323
|
-
| `@Tags('name')` | `@Tags('Users')` |
|
|
324
|
-
| `@Body()` | `@Body() data: Request` |
|
|
325
|
-
| `@Query()` | `@Query() search?: string` |
|
|
326
|
-
| `@Path()` | `@Path() id: string` |
|
|
327
|
-
| `@Header()` | `@Header('x-api-key') key: string` |
|
|
328
|
-
| `@Security('bearerAuth')` | Mark endpoint as requiring auth |
|
|
329
|
-
|
|
330
|
-
**Note**: Apply `@Tags()` to methods, not class.
|
|
331
|
-
|
|
332
|
-
### Manual Routes
|
|
333
|
-
|
|
334
|
-
For routes without OpenAPI docs:
|
|
335
|
-
|
|
336
|
-
```typescript
|
|
337
|
-
import { createAuthMW } from 'fa-mcp-sdk';
|
|
338
|
-
|
|
339
|
-
const authMW = createAuthMW();
|
|
340
|
-
apiRouter.get('/internal/status', authMW, (req, res) => {
|
|
341
|
-
res.json({ status: 'ok' });
|
|
342
|
-
});
|
|
343
|
-
```
|
|
344
|
-
|
|
345
|
-
## OpenAPI Types
|
|
346
|
-
|
|
347
|
-
```typescript
|
|
348
|
-
import { configureOpenAPI, OpenAPISpecResponse, SwaggerUIConfig } from 'fa-mcp-sdk';
|
|
349
|
-
|
|
350
|
-
interface OpenAPISpecResponse {
|
|
351
|
-
openapi: string; // '3.0.0'
|
|
352
|
-
info: { title: string; version: string; description?: string };
|
|
353
|
-
servers?: Array<{ url: string; description: string }>;
|
|
354
|
-
paths: Record<string, any>;
|
|
355
|
-
components?: { schemas?: Record<string, any>; securitySchemes?: Record<string, any> };
|
|
356
|
-
tags?: Array<{ name: string; description: string }>;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
interface SwaggerUIConfig {
|
|
360
|
-
customCss?: string;
|
|
361
|
-
customSiteTitle?: string;
|
|
362
|
-
customfavIcon?: string;
|
|
363
|
-
swaggerOptions?: {
|
|
364
|
-
persistAuthorization?: boolean;
|
|
365
|
-
displayRequestDuration?: boolean;
|
|
366
|
-
docExpansion?: 'none' | 'list' | 'full';
|
|
367
|
-
defaultModelsExpandDepth?: number;
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
```
|
|
371
|
-
|
|
372
|
-
### Swagger Config
|
|
373
|
-
|
|
374
|
-
```yaml
|
|
375
|
-
# config/default.yaml
|
|
376
|
-
swagger:
|
|
377
|
-
servers:
|
|
378
|
-
- url: 'https://api.example.com'
|
|
379
|
-
description: 'Production'
|
|
380
|
-
|
|
381
|
-
webServer:
|
|
382
|
-
auth:
|
|
383
|
-
enabled: true # Adds Bearer auth to spec
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
### Example: Complete API Setup
|
|
388
|
-
|
|
389
|
-
```typescript
|
|
390
|
-
// src/api/router.ts
|
|
391
|
-
import { Router } from 'express';
|
|
392
|
-
import { Route, Get, Post, Body, Tags, Security } from 'tsoa';
|
|
393
|
-
|
|
394
|
-
export const apiRouter: Router = Router();
|
|
395
|
-
|
|
396
|
-
interface DataResponse {
|
|
397
|
-
id: string;
|
|
398
|
-
value: string;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
@Route('api')
|
|
402
|
-
export class DataController {
|
|
403
|
-
/**
|
|
404
|
-
* Get data by ID
|
|
405
|
-
* @param id Unique identifier
|
|
406
|
-
*/
|
|
407
|
-
@Get('data/{id}')
|
|
408
|
-
@Tags('Data')
|
|
409
|
-
@Security('bearerAuth')
|
|
410
|
-
public async getData(id: string): Promise<DataResponse> {
|
|
411
|
-
return { id, value: 'example' };
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Create new data entry
|
|
416
|
-
*/
|
|
417
|
-
@Post('data')
|
|
418
|
-
@Tags('Data')
|
|
419
|
-
@Security('bearerAuth')
|
|
420
|
-
public async createData(
|
|
421
|
-
@Body() body: { value: string }
|
|
422
|
-
): Promise<DataResponse> {
|
|
423
|
-
return { id: 'new-id', value: body.value };
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
After starting the server with this controller:
|
|
429
|
-
- Swagger UI available at `/docs`
|
|
430
|
-
- Endpoints documented with authentication requirements
|
|
431
|
-
- Request/response schemas generated from TypeScript types
|
|
1
|
+
# Tools and REST API
|
|
2
|
+
|
|
3
|
+
## Tool Development
|
|
4
|
+
|
|
5
|
+
### Tool Definition (`src/tools/tools.ts`)
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
+
|
|
10
|
+
export const tools: Tool[] = [{
|
|
11
|
+
name: 'my_custom_tool',
|
|
12
|
+
description: 'Description of what this tool does',
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
query: { type: 'string', description: 'Input query' },
|
|
17
|
+
options: { type: 'object', description: 'Optional config' },
|
|
18
|
+
},
|
|
19
|
+
required: ['query'],
|
|
20
|
+
},
|
|
21
|
+
}];
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Tool Handler (`src/tools/handle-tool-call.ts`)
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { formatToolResult, ToolExecutionError, logger, IToolHandlerParams } from 'fa-mcp-sdk';
|
|
28
|
+
|
|
29
|
+
export const handleToolCall = async (params: IToolHandlerParams): Promise<any> => {
|
|
30
|
+
const { name, arguments: args, headers, payload, transport } = params;
|
|
31
|
+
// payload: { user: string, ... } if JWT auth enabled
|
|
32
|
+
// transport: 'stdio' | 'sse' | 'http'
|
|
33
|
+
// headers: normalized lowercase keys
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
switch (name) {
|
|
37
|
+
case 'my_custom_tool':
|
|
38
|
+
if (!args?.query) throw new ToolExecutionError(name, 'Query required');
|
|
39
|
+
return formatToolResult({ message: `Processed: ${args.query}` });
|
|
40
|
+
default:
|
|
41
|
+
throw new ToolExecutionError(name, `Unknown tool: ${name}`);
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
logger.error(`Tool ${name} failed:`, error);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Headers Access
|
|
51
|
+
|
|
52
|
+
Headers are normalized to lowercase. Available in HTTP/SSE transports:
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
const authHeader = headers?.authorization;
|
|
56
|
+
const userAgent = headers?.['user-agent'];
|
|
57
|
+
const clientIP = headers?.['x-real-ip'] || headers?.['x-forwarded-for'];
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Transport-Based Credentials
|
|
61
|
+
|
|
62
|
+
`IToolHandlerParams` includes `ITransportContext` fields (`transport`, `headers`, `payload`).
|
|
63
|
+
See [ITransportContext](./02-2-prompts-and-resources.md#itransportcontext).
|
|
64
|
+
|
|
65
|
+
### Outbound Webhooks (`x-web-hook`)
|
|
66
|
+
|
|
67
|
+
Handler-level pattern. The SDK does **not** ship a built-in webhook dispatcher — it exposes
|
|
68
|
+
everything you need (`params.headers`, `appConfig`, `logger`) and leaves the policy to the project.
|
|
69
|
+
This section is the **canonical recipe**: implement it as written so every fa-mcp-sdk-based MCP
|
|
70
|
+
server behaves the same way for clients and downstream collectors.
|
|
71
|
+
|
|
72
|
+
**What it is:** after every tool invocation the server can `POST` the tool result to an external
|
|
73
|
+
URL. Useful for audit trails, real-time dashboards, chaining MCP calls into CI/automation pipelines.
|
|
74
|
+
Opt-in per request (via header) and optionally per tool (via the response object). A failing webhook
|
|
75
|
+
**must never** fail the tool call.
|
|
76
|
+
|
|
77
|
+
#### Contract (stable across all MCPs)
|
|
78
|
+
|
|
79
|
+
**Inbound — precedence:**
|
|
80
|
+
|
|
81
|
+
| Source | Form | Precedence |
|
|
82
|
+
|---------------------|---------------------------------------------------------|------------|
|
|
83
|
+
| Per-tool override | `IToolResponse.hook: string` returned by the handler | wins |
|
|
84
|
+
| Per-request header | `x-web-hook: <http(s) URL>` | fallback |
|
|
85
|
+
|
|
86
|
+
If neither is present, no webhook fires.
|
|
87
|
+
|
|
88
|
+
**Outbound request:**
|
|
89
|
+
|
|
90
|
+
- Method: `POST`, `Content-Type: application/json`, timeout ≤ 10 000 ms
|
|
91
|
+
- Body:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"mcpName": "<appConfig.name>",
|
|
96
|
+
"tool": "<tool_name>",
|
|
97
|
+
"user": "<caller-id-or-omitted>",
|
|
98
|
+
"response": { "...": "tool's full JSON result" }
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| Field | Description |
|
|
103
|
+
|------------|------------------------------------------------------------------------------|
|
|
104
|
+
| `mcpName` | `appConfig.name` — identifies which MCP sent the callback |
|
|
105
|
+
| `tool` | Name of the invoked tool |
|
|
106
|
+
| `user` | Best-effort caller identity (see *User resolution*); **omit** if unresolved |
|
|
107
|
+
| `response` | Full JSON returned by the tool handler (same payload sent to the client) |
|
|
108
|
+
|
|
109
|
+
Do **not** add ad-hoc fields on a per-project basis without versioning the body — downstream
|
|
110
|
+
collectors rely on this exact shape.
|
|
111
|
+
|
|
112
|
+
#### Implementation recipe
|
|
113
|
+
|
|
114
|
+
**1. Declare the header** so `use://http-headers`, Agent Tester, and tool-call introspection
|
|
115
|
+
advertise it:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// src/start.ts
|
|
119
|
+
usedHttpHeaders.push({
|
|
120
|
+
name: 'x-web-hook',
|
|
121
|
+
description:
|
|
122
|
+
'Optional URL called via POST after each tool invocation. '
|
|
123
|
+
+ 'Body: { mcpName, tool, user, response }. Fire-and-forget; failures are logged only.',
|
|
124
|
+
isOptional: true,
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**2. Add `hook?` to the internal tool-response type** (lets a handler override the URL per tool):
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
// src/_types_/tool.ts
|
|
132
|
+
export interface IToolResponse {
|
|
133
|
+
text: string;
|
|
134
|
+
json: Record<string, any>;
|
|
135
|
+
hook?: string; // per-tool URL override; takes precedence over x-web-hook header
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**3. Dispatcher — fire-and-forget, never throws:**
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// src/tools/tools-manager.ts
|
|
143
|
+
import axios from 'axios';
|
|
144
|
+
import { appConfig, logger as lgr, toStr } from 'fa-mcp-sdk';
|
|
145
|
+
|
|
146
|
+
const logger = lgr.getSubLogger({ name: 'tools' });
|
|
147
|
+
const URL_REGEX = /^https?:\/\/[^\s]+$/i;
|
|
148
|
+
|
|
149
|
+
const callWebHook = (
|
|
150
|
+
url: string,
|
|
151
|
+
toolName: string,
|
|
152
|
+
json: Record<string, any>,
|
|
153
|
+
user?: string,
|
|
154
|
+
): void => {
|
|
155
|
+
if (!URL_REGEX.test(url)) { return; } // silently drop garbage URLs
|
|
156
|
+
const body = { mcpName: appConfig.name, tool: toolName, response: json, user };
|
|
157
|
+
axios.post(url, body, { timeout: 10_000 })
|
|
158
|
+
.catch((err) => logger.warn(`Web-hook POST ${url} failed: ${toStr(err?.message || err)}`));
|
|
159
|
+
};
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Rules:
|
|
163
|
+
|
|
164
|
+
- **No `await`.** The webhook must not delay the MCP response.
|
|
165
|
+
- **No re-throws.** A 5xx, timeout, or DNS failure is a `warn` log, nothing more.
|
|
166
|
+
- **URL allow-list.** At minimum, require `http(s)://`. Add an internal-net allow-list via config
|
|
167
|
+
(e.g. `webhook.allowedHosts`) if the threat model requires it (see *Security*).
|
|
168
|
+
|
|
169
|
+
**4. Wire it into the tool-call entry point** — dispatch after the handler resolves and before
|
|
170
|
+
the result is returned:
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
export const handleToolCall = async (params: IToolHandlerParams): Promise<any> => {
|
|
174
|
+
const { name: toolName, arguments: args, headers: mcpRequestHeaders = {} } = params;
|
|
175
|
+
|
|
176
|
+
const tool = (await getTools(mcpRequestHeaders)).get(toolName);
|
|
177
|
+
if (!tool?.handler) { throw new ToolExecutionError(toolName, `Unknown tool: ${toolName}`); }
|
|
178
|
+
|
|
179
|
+
const ctx: ToolContext = {
|
|
180
|
+
httpClient: createHttpClient(mcpRequestHeaders),
|
|
181
|
+
logger: logger.getSubLogger({ name: toolName }),
|
|
182
|
+
mcpRequestHeaders,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const toolResponse: IToolResponse = await tool.handler(args, ctx);
|
|
186
|
+
|
|
187
|
+
// ─── webhook dispatch (fire-and-forget) ─────────────────────────────────────
|
|
188
|
+
const hookUrl = (toolResponse?.hook || mcpRequestHeaders['x-web-hook'] || '').trim();
|
|
189
|
+
if (hookUrl) {
|
|
190
|
+
const syncUser = resolveActualUser(mcpRequestHeaders); // see step 5
|
|
191
|
+
if (syncUser) {
|
|
192
|
+
callWebHook(hookUrl, toolName, toolResponse.json, syncUser);
|
|
193
|
+
} else {
|
|
194
|
+
// Async user resolution — still fire-and-forget; do not block the tool response.
|
|
195
|
+
getCachedSelfUser(ctx.httpClient, mcpRequestHeaders)
|
|
196
|
+
.then((u) => callWebHook(hookUrl, toolName, toolResponse.json, u));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
return formatToolResult(toolResponse);
|
|
202
|
+
};
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**5. User resolution — best-effort, two-step.** The `user` field is what makes the webhook useful
|
|
206
|
+
for audit. Resolve carefully, but never let resolution fail the call.
|
|
207
|
+
|
|
208
|
+
- **Step A — Sync (preferred):** derive from headers / JWT payload / config without I/O
|
|
209
|
+
(e.g. JWT `payload.user`, a custom `x-actual-user` header your auth layer stamps, etc.).
|
|
210
|
+
- **Step B — Async fallback (only when sync returns nothing):** call the upstream "who am I"
|
|
211
|
+
endpoint with the same auth, **cache the result** (recommended TTL: 1 h, key by hashed
|
|
212
|
+
`Authorization`), and dedupe in-flight requests (thundering-herd protection).
|
|
213
|
+
- If both steps fail → **omit** the `user` field. Never invent a placeholder like `"unknown"`.
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
export function resolveActualUser (headers: Record<string, string>): string | undefined { /* … */ }
|
|
217
|
+
|
|
218
|
+
export const getCachedSelfUser = async (
|
|
219
|
+
httpClient: AxiosInstance,
|
|
220
|
+
headers: Record<string, string>,
|
|
221
|
+
): Promise<string | undefined> => { /* GET /me, cache by hashed Authorization, dedupe */ };
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
#### Per-tool override — when to use
|
|
225
|
+
|
|
226
|
+
A handler may force a specific webhook URL:
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
return { text, json, hook: 'https://collector.internal/special' };
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Use sparingly. Legitimate cases:
|
|
233
|
+
|
|
234
|
+
- a long-running tool whose result feeds a fixed pipeline regardless of the client;
|
|
235
|
+
- a tool that should **never** webhook (e.g. read of a secret) — return `hook: ''` only if the
|
|
236
|
+
dispatcher treats empty string as "skip even if header is set". With the snippet above this works
|
|
237
|
+
naturally because `(toolResponse?.hook || header)` short-circuits on any truthy `hook`; to force
|
|
238
|
+
skip, have the handler strip the header from `ctx` or short-circuit `hookUrl` explicitly.
|
|
239
|
+
|
|
240
|
+
If neither applies, do not set `hook` — let the client decide.
|
|
241
|
+
|
|
242
|
+
#### Security
|
|
243
|
+
|
|
244
|
+
- **URL validation** — reject anything that does not match `http(s)://…`. For public-facing MCPs,
|
|
245
|
+
restrict to a configured allow-list (`webhook.allowedHosts` in `config/default.yaml`).
|
|
246
|
+
- **SSRF surface** — the webhook is a server-side `POST` to a client-supplied URL. Acceptable for
|
|
247
|
+
trusted MCP clients; not acceptable open on the internet without an allow-list.
|
|
248
|
+
- **No secrets in the body** — `response` is the same JSON the client already received. Do **not**
|
|
249
|
+
add credentials, raw tokens, or PII not present in the response.
|
|
250
|
+
- **No retries** — duplicate POSTs to a flaky collector are worse than a missed event. If the
|
|
251
|
+
collector needs guarantees, let it poll.
|
|
252
|
+
- **Logging** — log `tool`, target host, and outcome at `warn`/`debug`; **never** log the full body
|
|
253
|
+
at `info` level (audit log noise + potential PII).
|
|
254
|
+
|
|
255
|
+
#### Testing checklist
|
|
256
|
+
|
|
257
|
+
- [ ] Header declared in `usedHttpHeaders` and visible at `/use://http-headers`.
|
|
258
|
+
- [ ] Tool call **without** `x-web-hook` → no outbound POST.
|
|
259
|
+
- [ ] Tool call **with** valid `x-web-hook` → exactly one POST, body matches the contract above.
|
|
260
|
+
- [ ] Collector returns 500 → tool response still succeeds; one `warn` line in the log.
|
|
261
|
+
- [ ] Collector hangs → tool response returns within normal latency; POST aborts at 10 s.
|
|
262
|
+
- [ ] Malformed URL (`javascript:…`, missing scheme) → no POST, no error to client.
|
|
263
|
+
- [ ] Per-tool `hook` set → wins over the header.
|
|
264
|
+
- [ ] Sync user resolution hits → `user` populated immediately, no extra HTTP call.
|
|
265
|
+
- [ ] Sync empty, async succeeds → POST fires after `/me` resolves; tool response was not delayed.
|
|
266
|
+
- [ ] Both user paths fail → POST fires with `user` **field omitted** (not `null`, not `"unknown"`).
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
## REST API Endpoints
|
|
270
|
+
|
|
271
|
+
Define REST endpoints in `src/api/router.ts` using [tsoa](https://tsoa-community.github.io/docs/) decorators.
|
|
272
|
+
|
|
273
|
+
### OpenAPI Generation
|
|
274
|
+
|
|
275
|
+
- **Auto-generated** on startup if `swagger/openapi.yaml` missing
|
|
276
|
+
- **Swagger UI**: `/docs`
|
|
277
|
+
- **Spec**: `/api/openapi.json`, `/api/openapi.yaml`
|
|
278
|
+
- Regenerate: delete `swagger/openapi.yaml` and restart
|
|
279
|
+
|
|
280
|
+
### Controller Example
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
import { Router } from 'express';
|
|
284
|
+
import { Route, Get, Post, Body, Tags, Query } from 'tsoa';
|
|
285
|
+
import { logger } from 'fa-mcp-sdk';
|
|
286
|
+
|
|
287
|
+
export const apiRouter: Router = Router();
|
|
288
|
+
|
|
289
|
+
interface UserResponse { id: string; name: string; email: string; }
|
|
290
|
+
interface CreateUserRequest { name: string; email: string; }
|
|
291
|
+
|
|
292
|
+
@Route('api')
|
|
293
|
+
export class UserController {
|
|
294
|
+
@Get('users/{userId}')
|
|
295
|
+
@Tags('Users')
|
|
296
|
+
public async getUser(userId: string): Promise<UserResponse> {
|
|
297
|
+
return { id: userId, name: 'John', email: 'john@example.com' };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
@Post('users')
|
|
301
|
+
@Tags('Users')
|
|
302
|
+
public async createUser(@Body() body: CreateUserRequest): Promise<UserResponse> {
|
|
303
|
+
return { id: 'new-id', name: body.name, email: body.email };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
@Get('users')
|
|
307
|
+
@Tags('Users')
|
|
308
|
+
public async searchUsers(@Query() query?: string, @Query() limit?: number): Promise<UserResponse[]> {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### tsoa Decorators
|
|
315
|
+
|
|
316
|
+
| Decorator | Example |
|
|
317
|
+
|-----------|---------|
|
|
318
|
+
| `@Route('prefix')` | `@Route('api')` |
|
|
319
|
+
| `@Get('path')` | `@Get('users/{id}')` |
|
|
320
|
+
| `@Post('path')` | `@Post('users')` |
|
|
321
|
+
| `@Put('path')` | `@Put('users/{id}')` |
|
|
322
|
+
| `@Delete('path')` | `@Delete('users/{id}')` |
|
|
323
|
+
| `@Tags('name')` | `@Tags('Users')` |
|
|
324
|
+
| `@Body()` | `@Body() data: Request` |
|
|
325
|
+
| `@Query()` | `@Query() search?: string` |
|
|
326
|
+
| `@Path()` | `@Path() id: string` |
|
|
327
|
+
| `@Header()` | `@Header('x-api-key') key: string` |
|
|
328
|
+
| `@Security('bearerAuth')` | Mark endpoint as requiring auth |
|
|
329
|
+
|
|
330
|
+
**Note**: Apply `@Tags()` to methods, not class.
|
|
331
|
+
|
|
332
|
+
### Manual Routes
|
|
333
|
+
|
|
334
|
+
For routes without OpenAPI docs:
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
import { createAuthMW } from 'fa-mcp-sdk';
|
|
338
|
+
|
|
339
|
+
const authMW = createAuthMW();
|
|
340
|
+
apiRouter.get('/internal/status', authMW, (req, res) => {
|
|
341
|
+
res.json({ status: 'ok' });
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## OpenAPI Types
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
import { configureOpenAPI, OpenAPISpecResponse, SwaggerUIConfig } from 'fa-mcp-sdk';
|
|
349
|
+
|
|
350
|
+
interface OpenAPISpecResponse {
|
|
351
|
+
openapi: string; // '3.0.0'
|
|
352
|
+
info: { title: string; version: string; description?: string };
|
|
353
|
+
servers?: Array<{ url: string; description: string }>;
|
|
354
|
+
paths: Record<string, any>;
|
|
355
|
+
components?: { schemas?: Record<string, any>; securitySchemes?: Record<string, any> };
|
|
356
|
+
tags?: Array<{ name: string; description: string }>;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
interface SwaggerUIConfig {
|
|
360
|
+
customCss?: string;
|
|
361
|
+
customSiteTitle?: string;
|
|
362
|
+
customfavIcon?: string;
|
|
363
|
+
swaggerOptions?: {
|
|
364
|
+
persistAuthorization?: boolean;
|
|
365
|
+
displayRequestDuration?: boolean;
|
|
366
|
+
docExpansion?: 'none' | 'list' | 'full';
|
|
367
|
+
defaultModelsExpandDepth?: number;
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Swagger Config
|
|
373
|
+
|
|
374
|
+
```yaml
|
|
375
|
+
# config/default.yaml
|
|
376
|
+
swagger:
|
|
377
|
+
servers:
|
|
378
|
+
- url: 'https://api.example.com'
|
|
379
|
+
description: 'Production'
|
|
380
|
+
|
|
381
|
+
webServer:
|
|
382
|
+
auth:
|
|
383
|
+
enabled: true # Adds Bearer auth to spec
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
### Example: Complete API Setup
|
|
388
|
+
|
|
389
|
+
```typescript
|
|
390
|
+
// src/api/router.ts
|
|
391
|
+
import { Router } from 'express';
|
|
392
|
+
import { Route, Get, Post, Body, Tags, Security } from 'tsoa';
|
|
393
|
+
|
|
394
|
+
export const apiRouter: Router = Router();
|
|
395
|
+
|
|
396
|
+
interface DataResponse {
|
|
397
|
+
id: string;
|
|
398
|
+
value: string;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
@Route('api')
|
|
402
|
+
export class DataController {
|
|
403
|
+
/**
|
|
404
|
+
* Get data by ID
|
|
405
|
+
* @param id Unique identifier
|
|
406
|
+
*/
|
|
407
|
+
@Get('data/{id}')
|
|
408
|
+
@Tags('Data')
|
|
409
|
+
@Security('bearerAuth')
|
|
410
|
+
public async getData(id: string): Promise<DataResponse> {
|
|
411
|
+
return { id, value: 'example' };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Create new data entry
|
|
416
|
+
*/
|
|
417
|
+
@Post('data')
|
|
418
|
+
@Tags('Data')
|
|
419
|
+
@Security('bearerAuth')
|
|
420
|
+
public async createData(
|
|
421
|
+
@Body() body: { value: string }
|
|
422
|
+
): Promise<DataResponse> {
|
|
423
|
+
return { id: 'new-id', value: body.value };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
After starting the server with this controller:
|
|
429
|
+
- Swagger UI available at `/docs`
|
|
430
|
+
- Endpoints documented with authentication requirements
|
|
431
|
+
- Request/response schemas generated from TypeScript types
|