fa-mcp-sdk 0.4.67 → 0.4.68

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.
@@ -62,65 +62,208 @@ const clientIP = headers?.['x-real-ip'] || headers?.['x-forwarded-for'];
62
62
  `IToolHandlerParams` includes `ITransportContext` fields (`transport`, `headers`, `payload`).
63
63
  See [ITransportContext](./02-2-prompts-and-resources.md#itransportcontext).
64
64
 
65
- ### Outbound Webhooks
65
+ ### Outbound Webhooks (`x-web-hook`)
66
66
 
67
- The SDK does not ship a built-in webhook — it is a **handler-level pattern** enabled by
68
- the fact that `params.headers` already carries every client header through to the tool.
69
- Use it when the caller should be notified of each tool result (audit, dashboards, CI
70
- chains). Reference implementation: `mcp-jira` (`src/tools/tools-manager.ts`,
71
- `callWebHook` + dispatch block).
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.
72
71
 
73
- **Recipe:**
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.
74
76
 
75
- 1. **Declare the header** so Agent Tester and `use://http-headers` advertise it:
77
+ #### Contract (stable across all MCPs)
76
78
 
77
- ```typescript
78
- usedHttpHeaders: [
79
- { name: 'x-web-hook', description: 'URL to POST the tool result to.', isOptional: true },
80
- ],
81
- ```
79
+ **Inbound — precedence:**
82
80
 
83
- 2. **Dispatch inside the handler** — fire-and-forget, never throw, never block the reply:
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 |
84
85
 
85
- ```typescript
86
- import axios from 'axios';
87
- import { appConfig, logger, toStr, IToolHandlerParams } from 'fa-mcp-sdk';
86
+ If neither is present, no webhook fires.
88
87
 
89
- const URL_REGEX = /^https?:\/\/[^\s]+$/i;
88
+ **Outbound request:**
90
89
 
91
- const callWebHook = (url: string, tool: string, response: unknown, user?: string): void => {
92
- if (!URL_REGEX.test(url)) { return; }
93
- axios.post(url, { mcpName: appConfig.name, tool, user, response }, { timeout: 10_000 })
94
- .catch((err) => logger.warn(`Web-hook POST ${url} failed: ${toStr(err?.message || err)}`));
95
- };
90
+ - Method: `POST`, `Content-Type: application/json`, timeout 10 000 ms
91
+ - Body:
96
92
 
97
- export const handleToolCall = async (params: IToolHandlerParams) => {
98
- const { name, headers = {} } = params;
99
- const result = await runTool(params); // produce { text, json, hook? }
100
- const hookUrl = (result.hook || headers['x-web-hook'] || '').trim();
101
- if (hookUrl) { callWebHook(hookUrl, name, result.json, resolveUser(headers, params.payload)); }
102
- return formatToolResult(result.json);
103
- };
104
- ```
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:
105
163
 
106
- 3. **Per-tool override (optional)** let a tool return its own `hook` URL that wins over
107
- the client header. Extend your internal tool-response type:
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*).
108
168
 
109
- ```typescript
110
- export interface IToolResponse { text: string; json: Record<string, any>; hook?: string; }
111
- ```
169
+ **4. Wire it into the tool-call entry point** — dispatch after the handler resolves and before
170
+ the result is returned:
112
171
 
113
- **Body contract** (recommended; keep stable across tools):
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
+ ```
114
204
 
115
- | Field | Description |
116
- |-------|-------------|
117
- | `mcpName` | `appConfig.name` — which MCP sent the callback |
118
- | `tool` | Tool name that was invoked |
119
- | `user` | Caller identity (JWT `payload.user`, auth header, or a lookup project-specific) |
120
- | `response` | Full JSON the tool produced |
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
+ ```
121
231
 
122
- **Rules of thumb:** validate the URL (`http(s)://…`), short timeout (≤10 s), catch+log
123
- only, **never** `await` the POST, and never let a webhook failure surface as a tool error.
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"`).
124
267
 
125
268
 
126
269
  ## REST API Endpoints
@@ -50,7 +50,7 @@
50
50
  "dependencies": {
51
51
  "@modelcontextprotocol/sdk": "^1.29.0",
52
52
  "dotenv": "^17.4.1",
53
- "fa-mcp-sdk": "^0.4.67"
53
+ "fa-mcp-sdk": "^0.4.68"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/express": "^5.0.6",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "fa-mcp-sdk",
3
3
  "productName": "FA MCP SDK",
4
- "version": "0.4.67",
4
+ "version": "0.4.68",
5
5
  "description": "Core infrastructure and templates for building Model Context Protocol (MCP) servers with TypeScript",
6
6
  "type": "module",
7
7
  "main": "dist/core/index.js",
@@ -1,23 +1,36 @@
1
1
  #!/usr/bin/env node
2
- import { cpSync, existsSync, rmSync } from 'fs';
3
- import { join } from 'path';
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
+ import { basename, dirname, join } from 'path';
4
4
 
5
5
  const templateDir = join(process.cwd(), './node_modules/fa-mcp-sdk/cli-template');
6
6
  const cwd = process.cwd();
7
7
 
8
8
  const targets = [
9
9
  { name: 'FA-MCP-SDK-DOC', src: join(templateDir, 'FA-MCP-SDK-DOC'), dest: join(cwd, 'FA-MCP-SDK-DOC') },
10
- { name: '.claude', src: join(templateDir, '.claude'), dest: join(cwd, '.claude') },
10
+ { name: '.claude', src: join(templateDir, '.claude'), dest: join(cwd, '.claude'), preserve: ['settings.json'] },
11
11
  ];
12
12
 
13
- for (const { name, src, dest } of targets) {
13
+ for (const { name, src, dest, preserve = [] } of targets) {
14
14
  if (!existsSync(src)) {
15
15
  console.error('Source not found:', src);
16
16
  process.exit(1);
17
17
  }
18
+ const saved = {};
19
+ for (const file of preserve) {
20
+ const p = join(dest, file);
21
+ if (existsSync(p)) saved[file] = readFileSync(p);
22
+ }
18
23
  if (existsSync(dest)) {
19
24
  rmSync(dest, { recursive: true });
20
25
  }
21
- cpSync(src, dest, { recursive: true });
26
+ cpSync(src, dest, {
27
+ recursive: true,
28
+ filter: (srcPath) => !preserve.includes(basename(srcPath)),
29
+ });
30
+ for (const [file, content] of Object.entries(saved)) {
31
+ const p = join(dest, file);
32
+ mkdirSync(dirname(p), { recursive: true });
33
+ writeFileSync(p, content);
34
+ }
22
35
  console.log(`${name} updated`);
23
36
  }