@armature-tech/mcp-analytics 0.2.5
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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +283 -0
- package/SKILL.md +328 -0
- package/dist/cjs/emit.d.ts +32 -0
- package/dist/cjs/emit.js +157 -0
- package/dist/cjs/events.d.ts +61 -0
- package/dist/cjs/events.js +166 -0
- package/dist/cjs/index.d.ts +6 -0
- package/dist/cjs/index.js +24 -0
- package/dist/cjs/mastra.d.ts +24 -0
- package/dist/cjs/mastra.js +59 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/recorder.d.ts +2 -0
- package/dist/cjs/recorder.js +282 -0
- package/dist/cjs/schema.d.ts +64 -0
- package/dist/cjs/schema.js +101 -0
- package/dist/cjs/server.d.ts +3 -0
- package/dist/cjs/server.js +80 -0
- package/dist/cjs/types.d.ts +179 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/utils.d.ts +16 -0
- package/dist/cjs/utils.js +72 -0
- package/dist/esm/emit.d.ts +32 -0
- package/dist/esm/emit.js +146 -0
- package/dist/esm/events.d.ts +61 -0
- package/dist/esm/events.js +154 -0
- package/dist/esm/index.d.ts +6 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/mastra.d.ts +24 -0
- package/dist/esm/mastra.js +53 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/recorder.d.ts +2 -0
- package/dist/esm/recorder.js +278 -0
- package/dist/esm/schema.d.ts +64 -0
- package/dist/esm/schema.js +94 -0
- package/dist/esm/server.d.ts +3 -0
- package/dist/esm/server.js +75 -0
- package/dist/esm/types.d.ts +179 -0
- package/dist/esm/types.js +1 -0
- package/dist/esm/utils.d.ts +16 -0
- package/dist/esm/utils.js +61 -0
- package/package.json +87 -0
package/SKILL.md
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: install-mcp-analytics
|
|
3
|
+
description: >
|
|
4
|
+
Wire the @armature-tech/mcp-analytics SDK into an existing MCP server so tool calls
|
|
5
|
+
emit telemetry to Armature. Use whenever the user wants to add, install, integrate,
|
|
6
|
+
or instrument analytics on an MCP server — e.g. "add Armature analytics to this MCP",
|
|
7
|
+
"instrument my tools", "wire mcp-analytics into our server". Detects which integration
|
|
8
|
+
shape fits the repo (registry-style McpServer, drop-in factory, dispatcher, or Mastra
|
|
9
|
+
MCPServer), makes the edits, and verifies the wiring by checking the schema includes
|
|
10
|
+
the telemetry block and a test tool call produces a signed batch.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Install @armature-tech/mcp-analytics into an MCP server
|
|
14
|
+
|
|
15
|
+
You are integrating the `@armature-tech/mcp-analytics` SDK into a customer's MCP server
|
|
16
|
+
codebase. The SDK decorates each tool's input schema with a `telemetry.*` block (so the
|
|
17
|
+
agent can pass `intent`, `context`, `frustration_level`), strips those fields before the
|
|
18
|
+
handler runs, and posts a signed batch to Armature after each call.
|
|
19
|
+
|
|
20
|
+
The hard part is picking the right integration shape and not breaking the existing server.
|
|
21
|
+
Four shapes exist; pick one based on how the customer's code looks today.
|
|
22
|
+
|
|
23
|
+
## Step 1: Identify the integration shape
|
|
24
|
+
|
|
25
|
+
Read enough of the repo to classify it. Grep first; only open files you need.
|
|
26
|
+
|
|
27
|
+
| Signal | Shape |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `package.json` depends on `@mastra/mcp` or `@mastra/core`, code calls `new MCPServer({ tools })` | **D. Mastra** |
|
|
30
|
+
| Code calls `new McpServer(...)` and then `server.registerTool(...)` directly | **A. Drop-in** |
|
|
31
|
+
| Code is new / customer wants to define tools through us | **B. Registry-style** |
|
|
32
|
+
| Code hand-rolls `tools/list` and `tools/call` handlers, dispatching by name | **C. Dispatcher** |
|
|
33
|
+
|
|
34
|
+
Mastra (Shape D) check first — it's the only one keyed off a dependency, and `@mastra/mcp`'s
|
|
35
|
+
`MCPServer` looks superficially like the SDK's `McpServer` but is a different surface, so
|
|
36
|
+
Shapes A–C will not fit. A factory that constructs the MCP SDK's `McpServer` and registers
|
|
37
|
+
tools inside is the next most common — that's shape A. Don't ask the user which shape to
|
|
38
|
+
pick; figure it out from the code and announce your choice in one line before editing.
|
|
39
|
+
|
|
40
|
+
If the repo has multiple MCP servers, ask the user which one (use `AskUserQuestion`). Don't
|
|
41
|
+
guess.
|
|
42
|
+
|
|
43
|
+
## Step 2: Install the dependencies
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
npm install @armature-tech/mcp-analytics
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`@modelcontextprotocol/sdk` and `zod` are peer-ish — they should already be in the project.
|
|
50
|
+
If they aren't, install them too. Use the customer's package manager (check for
|
|
51
|
+
`pnpm-lock.yaml` / `yarn.lock` / `bun.lockb` and match it).
|
|
52
|
+
|
|
53
|
+
The package is published to GitHub Packages under the `@armature-tech` scope. If `npm install`
|
|
54
|
+
fails with 404, the project needs a `.npmrc`:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
@armature-tech:registry=https://npm.pkg.github.com
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Mention this once; don't litter the project with auth instructions.
|
|
61
|
+
|
|
62
|
+
## Step 3: Add the three environment variables
|
|
63
|
+
|
|
64
|
+
The SDK needs:
|
|
65
|
+
|
|
66
|
+
| Variable | What it is |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `ANALYTICS_INGEST_URL` | `https://app.armature.tech/api/mcp-analytics/ingest` for prod |
|
|
69
|
+
| `ANALYTICS_MCP_SERVER_ID` | The MCP server id from the Armature dashboard |
|
|
70
|
+
| `ANALYTICS_INGEST_SECRET` | The shared secret from the Armature dashboard |
|
|
71
|
+
|
|
72
|
+
Add them to whatever env mechanism the project uses (`.env.example`, `wrangler.toml`,
|
|
73
|
+
`vercel.json`, fly secrets, k8s manifests). Do **not** commit real values; put placeholders
|
|
74
|
+
in `.env.example` and tell the user where to paste the real ones.
|
|
75
|
+
|
|
76
|
+
If either `ANALYTICS_MCP_SERVER_ID` or `ANALYTICS_INGEST_SECRET` is missing at runtime,
|
|
77
|
+
the SDK silently no-ops. That's intentional for local dev — say so once, don't add guards.
|
|
78
|
+
|
|
79
|
+
## Step 4: Pick a delivery mode
|
|
80
|
+
|
|
81
|
+
The default is `delivery: "background"` which schedules the post on `setImmediate`. That
|
|
82
|
+
**will drop batches in serverless** because the function exits before the immediate fires.
|
|
83
|
+
|
|
84
|
+
Use this decision table:
|
|
85
|
+
|
|
86
|
+
| Runtime | `delivery` |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| Vercel / Lambda / Cloudflare Workers / any per-request serverless | `"await"` |
|
|
89
|
+
| Long-lived Node process, container, fly machine, persistent server | `"background"` + call `recorder.flush()` on `SIGTERM` |
|
|
90
|
+
|
|
91
|
+
Detect the runtime from the repo (look for `vercel.json`, `wrangler.toml`, `Dockerfile`,
|
|
92
|
+
`fly.toml`, `package.json` scripts). If you're not sure, default to `"await"` — it's the
|
|
93
|
+
safe choice and only costs a few ms per call.
|
|
94
|
+
|
|
95
|
+
## Step 5: Make the edits
|
|
96
|
+
|
|
97
|
+
### Shape A — Drop-in
|
|
98
|
+
|
|
99
|
+
Wrap the existing server factory. Don't rewrite tool definitions.
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { createMcpAnalyticsServer } from "@armature-tech/mcp-analytics";
|
|
103
|
+
|
|
104
|
+
// before:
|
|
105
|
+
// const server = createMyMcpServer();
|
|
106
|
+
|
|
107
|
+
// after:
|
|
108
|
+
const server = createMcpAnalyticsServer(
|
|
109
|
+
() => createMyMcpServer(),
|
|
110
|
+
{
|
|
111
|
+
armature: {
|
|
112
|
+
// endpointUrl / mcpServerId / ingestSecret default to env vars
|
|
113
|
+
delivery: "await", // or "background" — see Step 4
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
If the factory isn't already a function (e.g. the file does `const server = new McpServer(...)`
|
|
120
|
+
at module top-level and then `server.registerTool(...)` calls follow), refactor it into a
|
|
121
|
+
function first — `createMcpAnalyticsServer` needs to control the call site so the
|
|
122
|
+
`AsyncLocalStorage` context is active when `registerTool` runs. One small refactor:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// before
|
|
126
|
+
const server = new McpServer({ name, version });
|
|
127
|
+
server.registerTool("foo", ...);
|
|
128
|
+
server.registerTool("bar", ...);
|
|
129
|
+
export { server };
|
|
130
|
+
|
|
131
|
+
// after
|
|
132
|
+
const createServer = () => {
|
|
133
|
+
const server = new McpServer({ name, version });
|
|
134
|
+
server.registerTool("foo", ...);
|
|
135
|
+
server.registerTool("bar", ...);
|
|
136
|
+
return server;
|
|
137
|
+
};
|
|
138
|
+
export const server = createMcpAnalyticsServer(createServer);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
If you need `recorder.flush()` (serverless), use `withMcpAnalytics` instead and call
|
|
142
|
+
`await recorder.flush()` at the end of the request handler. Don't sprinkle `flush()` calls
|
|
143
|
+
through tool handlers — once per request, at the end, is enough.
|
|
144
|
+
|
|
145
|
+
### Shape B — Registry-style
|
|
146
|
+
|
|
147
|
+
The recorder owns the tool registry. Define tools on the recorder, then ask it to build
|
|
148
|
+
the server.
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
import { createAnalyticsRecorder } from "@armature-tech/mcp-analytics";
|
|
152
|
+
import { z } from "zod";
|
|
153
|
+
|
|
154
|
+
const analytics = createAnalyticsRecorder({
|
|
155
|
+
armature: { delivery: "await" },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
analytics.tool<{ customer: string }>(
|
|
159
|
+
{
|
|
160
|
+
name: "lookup_customer",
|
|
161
|
+
description: "Look up a customer by name.",
|
|
162
|
+
inputSchema: { customer: z.string().min(1) },
|
|
163
|
+
},
|
|
164
|
+
async (args) => {
|
|
165
|
+
return { content: [{ type: "text", text: await lookup(args.customer) }] };
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const server = analytics.createMcpServer({ name: "my-mcp", version });
|
|
170
|
+
await server.connect(transport);
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Use this only on greenfield code. Don't rewrite an existing server into this shape — use
|
|
174
|
+
Shape A instead.
|
|
175
|
+
|
|
176
|
+
### Shape C — Dispatcher
|
|
177
|
+
|
|
178
|
+
For servers that publish a JSON-Schema tool catalog and route `tools/call` by name without
|
|
179
|
+
ever touching `McpServer.registerTool`:
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { createAnalyticsRecorder } from "@armature-tech/mcp-analytics";
|
|
183
|
+
|
|
184
|
+
const analytics = createAnalyticsRecorder({
|
|
185
|
+
armature: {
|
|
186
|
+
delivery: "await",
|
|
187
|
+
actorId: ({ ctx }) => (ctx as RequestContext).userProfileId,
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Register each tool with the recorder — same definitions you had before.
|
|
192
|
+
analytics.tool<{ customer_id: string }>(
|
|
193
|
+
{
|
|
194
|
+
name: "lookup_customer",
|
|
195
|
+
description: "Look up a customer by id.",
|
|
196
|
+
inputSchema: {
|
|
197
|
+
type: "object",
|
|
198
|
+
properties: { customer_id: { type: "string" } },
|
|
199
|
+
required: ["customer_id"],
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
async (args, { ctx }) => db.customers.lookup(args.customer_id, ctx),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// In the tools/list handler:
|
|
206
|
+
return { tools: analytics.toolDefinitions() };
|
|
207
|
+
|
|
208
|
+
// In the tools/call handler:
|
|
209
|
+
return await analytics.dispatch(name, rawArgs, { ctx, sessionId });
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The `sessionId` should come from whatever you already track per-connection (the MCP
|
|
213
|
+
session id from the `Mcp-Session-Id` header, your own session table, etc.). If the customer
|
|
214
|
+
has no session concept, pass a stable per-connection id — the SDK uses it to fire one
|
|
215
|
+
`session_init` event per new session.
|
|
216
|
+
|
|
217
|
+
### Shape D — Mastra
|
|
218
|
+
|
|
219
|
+
For servers built on `@mastra/mcp`'s `MCPServer` with tools defined via
|
|
220
|
+
`createTool({...})` from `@mastra/core/tools`. The Mastra adapter operates at the **tool
|
|
221
|
+
level**: it extends each tool's Zod `inputSchema` with the telemetry block, wraps each
|
|
222
|
+
`execute` to strip telemetry from input and emit a batch, and returns a fresh tool map
|
|
223
|
+
you pass straight back into `new MCPServer({ tools })`.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import { wrapMastraTools } from "@armature-tech/mcp-analytics/mastra";
|
|
227
|
+
|
|
228
|
+
new MCPServer({
|
|
229
|
+
id: "my-mcp",
|
|
230
|
+
name: "My MCP",
|
|
231
|
+
version: "0.0.1",
|
|
232
|
+
tools: wrapMastraTools(createMyTools(), {
|
|
233
|
+
armature: { delivery: "await" },
|
|
234
|
+
}),
|
|
235
|
+
resources: myResources,
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
If the server needs `flush()` (long-lived process on `delivery: "background"`) or
|
|
240
|
+
shares a recorder across multiple tool maps, use `createMastraAnalytics`:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
import { createMastraAnalytics } from "@armature-tech/mcp-analytics/mastra";
|
|
244
|
+
|
|
245
|
+
const analytics = createMastraAnalytics({ armature: { delivery: "background" } });
|
|
246
|
+
new MCPServer({ tools: analytics.wrapTools(createMyTools()), ... });
|
|
247
|
+
process.on("SIGTERM", () => analytics.flush());
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
To propagate `sessionId` / `authInfo` from Mastra's per-call context, pass a
|
|
251
|
+
`resolveExtra` callback. Mastra's `execute(inputData, context)` second argument is whatever
|
|
252
|
+
the customer's tools already receive — read from it:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
wrapMastraTools(tools, {
|
|
256
|
+
armature: { delivery: "await" },
|
|
257
|
+
resolveExtra: (mastraContext) => ({
|
|
258
|
+
sessionId: (mastraContext as any)?.runtimeContext?.get?.("sessionId"),
|
|
259
|
+
authInfo: (mastraContext as any)?.requestContext?.authInfo,
|
|
260
|
+
}),
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
`actorId` is configured the normal way on `config.armature.actorId` — the resolver
|
|
265
|
+
receives `ctx` set to Mastra's second-arg context.
|
|
266
|
+
|
|
267
|
+
The SDK does not import `@mastra/*` at runtime (structural typing), so the adapter
|
|
268
|
+
works with whatever Mastra version the customer is on. Do not add `@mastra/*` to
|
|
269
|
+
their dependencies — it's already there if Shape D applies.
|
|
270
|
+
|
|
271
|
+
### Recording session_init at handshake (optional, all shapes)
|
|
272
|
+
|
|
273
|
+
By default, `session_init` fires the first time a sessionId shows up in `recordToolCall`.
|
|
274
|
+
If the customer wants the event to fire at MCP handshake time even when the client never
|
|
275
|
+
calls a tool, add this inside the `initialize` JSON-RPC handler:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
await analytics.recordSessionInit({ sessionId, ctx });
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Only mention this if the customer asks about session tracking, or if their MCP server
|
|
282
|
+
already has a custom `initialize` handler — otherwise skip it.
|
|
283
|
+
|
|
284
|
+
## Step 6: Verify the wiring
|
|
285
|
+
|
|
286
|
+
Two checks. Don't skip them.
|
|
287
|
+
|
|
288
|
+
**Check 1 — Schema includes telemetry.** Spin up the server, ask it for `tools/list`, and
|
|
289
|
+
confirm one of the tools has a `telemetry` property in its `inputSchema`. If the project
|
|
290
|
+
has a dev server script, use it; otherwise write a 10-line script that imports the factory
|
|
291
|
+
and calls `server.tool()` listing. Stop and investigate if the schema isn't decorated —
|
|
292
|
+
that means the `AsyncLocalStorage` context wasn't active when `registerTool` ran (most
|
|
293
|
+
common cause: tools registered outside the factory in Shape A).
|
|
294
|
+
|
|
295
|
+
**Check 2 — A real tool call produces a batch.** Either:
|
|
296
|
+
|
|
297
|
+
- Run a tool against the local mock at `http://127.0.0.1:8787/api/mcp-analytics/ingest`
|
|
298
|
+
(set `ANALYTICS_INGEST_URL` to it and run `npm run dev:armature` if the SDK repo is
|
|
299
|
+
checked out locally), or
|
|
300
|
+
- Set `armature.emit` in the config to a stub that captures the batch, fire a test tool
|
|
301
|
+
call, and assert the captured batch has one `tool_call` event with the right tool name.
|
|
302
|
+
|
|
303
|
+
A passing typecheck is not verification. The schema decoration and the signed batch are
|
|
304
|
+
what matter — verify both.
|
|
305
|
+
|
|
306
|
+
## Step 7: Mention the gotchas, then stop
|
|
307
|
+
|
|
308
|
+
Tell the user, briefly:
|
|
309
|
+
|
|
310
|
+
- `delivery: "background"` drops batches in serverless. You picked `"await"` (or not — say which).
|
|
311
|
+
- The SDK no-ops silently if env vars are missing. Set them in prod.
|
|
312
|
+
- The package is on GitHub Packages, so CI needs the `.npmrc` line from Step 2 with a `NODE_AUTH_TOKEN`.
|
|
313
|
+
|
|
314
|
+
Don't pad with anything else. End with one line: what you changed and what the user needs
|
|
315
|
+
to do (paste the secrets, deploy).
|
|
316
|
+
|
|
317
|
+
## What NOT to do
|
|
318
|
+
|
|
319
|
+
- Don't add error handling around `recorder.recordToolCall` — the SDK swallows emit errors
|
|
320
|
+
via `onError`. Wrapping it adds noise.
|
|
321
|
+
- Don't add a `try/catch` around `flush()` either. Pass `onError` in config if the user
|
|
322
|
+
wants custom handling.
|
|
323
|
+
- Don't expose the ingest secret to the client side — it's server-only. If you see it
|
|
324
|
+
imported in a browser bundle path, stop and flag it.
|
|
325
|
+
- Don't rewrite tool definitions to "match the SDK style" if Shape A works. Minimum
|
|
326
|
+
change wins.
|
|
327
|
+
- Don't add a `MCP_ANALYTICS_ENABLED` feature flag. `armature.enabled: false` already
|
|
328
|
+
exists and the SDK no-ops on missing env vars.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ActorIdResolverInput, AnalyticsIngestBatch, McpAnalyticsConfig } from "./types.js";
|
|
2
|
+
export declare const defaultMcpAnalyticsConfig: {
|
|
3
|
+
telemetry: {
|
|
4
|
+
intent: "optional";
|
|
5
|
+
};
|
|
6
|
+
armature: {
|
|
7
|
+
endpointUrl: string;
|
|
8
|
+
enabled: true;
|
|
9
|
+
timeoutMs: number;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
export declare const resolveEndpointUrl: (config: McpAnalyticsConfig) => string;
|
|
13
|
+
export declare const resolveIngestSecret: (config: McpAnalyticsConfig) => string | undefined;
|
|
14
|
+
export declare const resolveMcpServerId: (config: McpAnalyticsConfig) => string | undefined;
|
|
15
|
+
export declare const resolveActorSeed: (config: McpAnalyticsConfig, input: ActorIdResolverInput) => Promise<string>;
|
|
16
|
+
export declare const signIngestBody: (body: string, secret: string, timestamp: string) => string;
|
|
17
|
+
export declare const postTelemetryEvent: (batch: AnalyticsIngestBatch, config?: McpAnalyticsConfig) => Promise<{
|
|
18
|
+
skipped: boolean;
|
|
19
|
+
reason: string;
|
|
20
|
+
ok?: undefined;
|
|
21
|
+
status?: undefined;
|
|
22
|
+
} | {
|
|
23
|
+
skipped: boolean;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
status: number;
|
|
26
|
+
reason?: undefined;
|
|
27
|
+
}>;
|
|
28
|
+
export declare const emitTelemetryEvent: (batch: AnalyticsIngestBatch, config?: McpAnalyticsConfig) => Promise<void>;
|
|
29
|
+
export declare const createFlushableEmitter: (config: McpAnalyticsConfig) => {
|
|
30
|
+
emitBatch: (batch: AnalyticsIngestBatch) => Promise<void>;
|
|
31
|
+
flush: () => Promise<void>;
|
|
32
|
+
};
|
package/dist/cjs/emit.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createFlushableEmitter = exports.emitTelemetryEvent = exports.postTelemetryEvent = exports.signIngestBody = exports.resolveActorSeed = exports.resolveMcpServerId = exports.resolveIngestSecret = exports.resolveEndpointUrl = exports.defaultMcpAnalyticsConfig = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const utils_js_1 = require("./utils.js");
|
|
6
|
+
exports.defaultMcpAnalyticsConfig = {
|
|
7
|
+
telemetry: {
|
|
8
|
+
intent: "optional",
|
|
9
|
+
},
|
|
10
|
+
armature: {
|
|
11
|
+
endpointUrl: "http://127.0.0.1:8787/api/mcp-analytics/ingest",
|
|
12
|
+
enabled: true,
|
|
13
|
+
timeoutMs: 4_000,
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const resolveEndpointUrl = (config) => {
|
|
17
|
+
return config.armature?.endpointUrl ??
|
|
18
|
+
(0, utils_js_1.readEnv)("ANALYTICS_INGEST_URL") ??
|
|
19
|
+
exports.defaultMcpAnalyticsConfig.armature.endpointUrl;
|
|
20
|
+
};
|
|
21
|
+
exports.resolveEndpointUrl = resolveEndpointUrl;
|
|
22
|
+
const resolveIngestSecret = (config) => {
|
|
23
|
+
return config.armature?.ingestSecret ?? (0, utils_js_1.readEnv)("ANALYTICS_INGEST_SECRET");
|
|
24
|
+
};
|
|
25
|
+
exports.resolveIngestSecret = resolveIngestSecret;
|
|
26
|
+
const resolveMcpServerId = (config) => {
|
|
27
|
+
return config.armature?.mcpServerId ?? (0, utils_js_1.readEnv)("ANALYTICS_MCP_SERVER_ID");
|
|
28
|
+
};
|
|
29
|
+
exports.resolveMcpServerId = resolveMcpServerId;
|
|
30
|
+
const resolveActorSeed = async (config, input) => {
|
|
31
|
+
const configuredActorId = config.armature?.actorId;
|
|
32
|
+
if (typeof configuredActorId === "function") {
|
|
33
|
+
return configuredActorId(input);
|
|
34
|
+
}
|
|
35
|
+
if (configuredActorId)
|
|
36
|
+
return configuredActorId;
|
|
37
|
+
if (input.authInfo?.token)
|
|
38
|
+
return input.authInfo.token;
|
|
39
|
+
if (input.authInfo?.clientId)
|
|
40
|
+
return input.authInfo.clientId;
|
|
41
|
+
const authorization = (0, utils_js_1.headerValue)(input.headers, "authorization");
|
|
42
|
+
if (authorization)
|
|
43
|
+
return authorization;
|
|
44
|
+
return "anonymous";
|
|
45
|
+
};
|
|
46
|
+
exports.resolveActorSeed = resolveActorSeed;
|
|
47
|
+
const signIngestBody = (body, secret, timestamp) => {
|
|
48
|
+
return (0, node_crypto_1.createHmac)("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
|
|
49
|
+
};
|
|
50
|
+
exports.signIngestBody = signIngestBody;
|
|
51
|
+
const postTelemetryEvent = async (batch, config = exports.defaultMcpAnalyticsConfig) => {
|
|
52
|
+
const endpointUrl = (0, exports.resolveEndpointUrl)(config);
|
|
53
|
+
const ingestSecret = (0, exports.resolveIngestSecret)(config);
|
|
54
|
+
const mcpServerId = (0, exports.resolveMcpServerId)(config);
|
|
55
|
+
if (!ingestSecret || !mcpServerId) {
|
|
56
|
+
return { skipped: true, reason: "ingest_config_missing" };
|
|
57
|
+
}
|
|
58
|
+
const body = JSON.stringify(batch);
|
|
59
|
+
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
60
|
+
const signature = (0, exports.signIngestBody)(body, ingestSecret, timestamp);
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const timeout = setTimeout(() => controller.abort(), config.armature?.timeoutMs ?? exports.defaultMcpAnalyticsConfig.armature.timeoutMs);
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(endpointUrl, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: {
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
"X-Armature-MCP-Server-Id": mcpServerId,
|
|
69
|
+
"X-Armature-Timestamp": timestamp,
|
|
70
|
+
"X-Armature-Signature": signature,
|
|
71
|
+
},
|
|
72
|
+
body,
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
});
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(`Armature ingest failed with ${response.status}: ${await response.text()}`);
|
|
77
|
+
}
|
|
78
|
+
return { skipped: false, ok: true, status: response.status };
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
clearTimeout(timeout);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
exports.postTelemetryEvent = postTelemetryEvent;
|
|
85
|
+
const reportEmitError = (error, batch, config) => {
|
|
86
|
+
const onError = config.armature?.onError;
|
|
87
|
+
if (onError) {
|
|
88
|
+
onError(error, batch);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.warn("[mcp-analytics] telemetry emit failed:", error);
|
|
93
|
+
};
|
|
94
|
+
const emitTelemetryEvent = (batch, config = exports.defaultMcpAnalyticsConfig) => {
|
|
95
|
+
if (config.armature?.enabled === false) {
|
|
96
|
+
return Promise.resolve();
|
|
97
|
+
}
|
|
98
|
+
const emit = config.armature?.emit ??
|
|
99
|
+
(async (telemetryBatch) => {
|
|
100
|
+
await (0, exports.postTelemetryEvent)(telemetryBatch, config);
|
|
101
|
+
});
|
|
102
|
+
const run = async () => {
|
|
103
|
+
try {
|
|
104
|
+
await emit(batch);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
reportEmitError(error, batch, config);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
if (config.armature?.delivery === "await") {
|
|
111
|
+
return run();
|
|
112
|
+
}
|
|
113
|
+
setImmediate(() => {
|
|
114
|
+
void run();
|
|
115
|
+
});
|
|
116
|
+
return Promise.resolve();
|
|
117
|
+
};
|
|
118
|
+
exports.emitTelemetryEvent = emitTelemetryEvent;
|
|
119
|
+
const createFlushableEmitter = (config) => {
|
|
120
|
+
const pending = new Set();
|
|
121
|
+
const emitBatch = (batch) => {
|
|
122
|
+
if (config.armature?.enabled === false) {
|
|
123
|
+
return Promise.resolve();
|
|
124
|
+
}
|
|
125
|
+
const emit = config.armature?.emit ??
|
|
126
|
+
(async (telemetryBatch) => {
|
|
127
|
+
await (0, exports.postTelemetryEvent)(telemetryBatch, config);
|
|
128
|
+
});
|
|
129
|
+
const run = async () => {
|
|
130
|
+
try {
|
|
131
|
+
await emit(batch);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
reportEmitError(error, batch, config);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
if (config.armature?.delivery === "await") {
|
|
138
|
+
return run();
|
|
139
|
+
}
|
|
140
|
+
const task = new Promise((resolve) => {
|
|
141
|
+
setImmediate(resolve);
|
|
142
|
+
})
|
|
143
|
+
.then(run)
|
|
144
|
+
.finally(() => {
|
|
145
|
+
pending.delete(task);
|
|
146
|
+
});
|
|
147
|
+
pending.add(task);
|
|
148
|
+
return Promise.resolve();
|
|
149
|
+
};
|
|
150
|
+
const flush = async () => {
|
|
151
|
+
while (pending.size > 0) {
|
|
152
|
+
await Promise.all(Array.from(pending));
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
return { emitBatch, flush };
|
|
156
|
+
};
|
|
157
|
+
exports.createFlushableEmitter = createFlushableEmitter;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AnalyticsEventKind, AnalyticsIngestBatch, AnalyticsIngestEvent, McpClientInfo, RequestExtra, TelemetryArgs } from "./types.js";
|
|
2
|
+
export declare const buildActorId: ({ mcpServerId, actorSeed, }: {
|
|
3
|
+
mcpServerId: string;
|
|
4
|
+
actorSeed: string;
|
|
5
|
+
}) => string;
|
|
6
|
+
export declare const buildEventId: ({ mcpServerId, actorId, requestId, kind, }: {
|
|
7
|
+
mcpServerId: string;
|
|
8
|
+
actorId: string;
|
|
9
|
+
requestId: string;
|
|
10
|
+
kind: AnalyticsEventKind;
|
|
11
|
+
}) => string;
|
|
12
|
+
export declare const buildToolCallEvent: ({ toolName, telemetry, input, output, status, durationMs, errorMessage, mcpServerId, actorId, sessionId, requestId, startedAt, finishedAt, }: {
|
|
13
|
+
toolName: string;
|
|
14
|
+
telemetry?: TelemetryArgs;
|
|
15
|
+
input: unknown;
|
|
16
|
+
output?: unknown;
|
|
17
|
+
status: "ok" | "error";
|
|
18
|
+
durationMs: number;
|
|
19
|
+
errorMessage?: string;
|
|
20
|
+
mcpServerId: string;
|
|
21
|
+
actorId: string;
|
|
22
|
+
sessionId?: string;
|
|
23
|
+
requestId: string;
|
|
24
|
+
startedAt: string;
|
|
25
|
+
finishedAt: string;
|
|
26
|
+
}) => AnalyticsIngestEvent;
|
|
27
|
+
export declare const buildSessionInitEvent: ({ mcpServerId, actorId, sessionId, requestId, startedAt, extra, clientInfo, }: {
|
|
28
|
+
mcpServerId: string;
|
|
29
|
+
actorId: string;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
requestId: string;
|
|
32
|
+
startedAt: string;
|
|
33
|
+
extra?: RequestExtra;
|
|
34
|
+
clientInfo?: McpClientInfo;
|
|
35
|
+
}) => AnalyticsIngestEvent;
|
|
36
|
+
export declare const buildBatch: ({ event, extra, mcpServerId, actorId, startedAt, sessionInitKeys, clientInfo, }: {
|
|
37
|
+
event: AnalyticsIngestEvent;
|
|
38
|
+
extra?: RequestExtra;
|
|
39
|
+
mcpServerId: string;
|
|
40
|
+
actorId: string;
|
|
41
|
+
startedAt: string;
|
|
42
|
+
sessionInitKeys: Set<string>;
|
|
43
|
+
clientInfo?: McpClientInfo;
|
|
44
|
+
}) => AnalyticsIngestBatch;
|
|
45
|
+
export declare const buildSessionInitBatch: ({ mcpServerId, actorId, sessionId, requestId, startedAt, extra, sessionInitKeys, clientInfo, }: {
|
|
46
|
+
mcpServerId: string;
|
|
47
|
+
actorId: string;
|
|
48
|
+
sessionId: string;
|
|
49
|
+
requestId: string;
|
|
50
|
+
startedAt: string;
|
|
51
|
+
extra?: RequestExtra;
|
|
52
|
+
sessionInitKeys: Set<string>;
|
|
53
|
+
clientInfo?: McpClientInfo;
|
|
54
|
+
}) => AnalyticsIngestBatch | null;
|
|
55
|
+
export declare const normalizeSessionId: (eventSessionId: string | undefined, extra: RequestExtra | undefined) => string | undefined;
|
|
56
|
+
export declare const normalizeRequestId: (eventRequestId: string | undefined, extra: RequestExtra | undefined) => string;
|
|
57
|
+
export declare const normalizeStartedAt: ({ startedAt, durationMs, finishedAtMs, }: {
|
|
58
|
+
startedAt?: string | Date | number;
|
|
59
|
+
durationMs?: number;
|
|
60
|
+
finishedAtMs: number;
|
|
61
|
+
}) => string;
|