@noodleseed/one 0.139.3 → 0.140.0

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.
Files changed (40) hide show
  1. package/dist/metrics-render.d.ts +8 -0
  2. package/dist/metrics-render.d.ts.map +1 -1
  3. package/dist/metrics-render.js +7 -1
  4. package/dist/metrics-render.js.map +1 -1
  5. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  6. package/node_modules/@noodle-borg/control-plane/dist/personal-workspace.d.ts +7 -0
  7. package/node_modules/@noodle-borg/{service → control-plane}/dist/personal-workspace.js +1 -2
  8. package/node_modules/@noodle-borg/control-plane/dist/portable.d.ts +1 -0
  9. package/node_modules/@noodle-borg/control-plane/dist/portable.js +1 -0
  10. package/node_modules/@noodle-borg/module/dist/contract.d.ts +0 -64
  11. package/node_modules/@noodle-borg/module/dist/contract.js +3 -2
  12. package/node_modules/@noodle-borg/module/dist/index.d.ts +1 -0
  13. package/node_modules/@noodle-borg/module/dist/index.js +1 -0
  14. package/node_modules/@noodle-borg/module/dist/intent.d.ts +1 -1
  15. package/node_modules/@noodle-borg/module/dist/request-analytics.d.ts +80 -0
  16. package/node_modules/@noodle-borg/module/dist/request-analytics.js +10 -0
  17. package/node_modules/@noodle-borg/observability/dist/intent-capture.js +4 -0
  18. package/node_modules/@noodle-borg/observability/dist/request-event-buffer.js +4 -0
  19. package/node_modules/@noodle-borg/observability/dist/request-event-metrics.js +15 -0
  20. package/node_modules/@noodle-borg/observability/dist/request-events-postgres.js +19 -3
  21. package/node_modules/@noodle-borg/observability/dist/request-events.js +3 -0
  22. package/node_modules/@noodle-borg/observability/dist/runtime.js +61 -1
  23. package/node_modules/@noodle-borg/protocol/dist/handlers/prompts.js +2 -2
  24. package/node_modules/@noodle-borg/protocol/dist/handlers/resources.js +2 -2
  25. package/node_modules/@noodle-borg/protocol/dist/handlers/tools.js +3 -3
  26. package/node_modules/@noodle-borg/protocol/dist/observation.d.ts +17 -0
  27. package/node_modules/@noodle-borg/protocol/dist/observation.js +14 -0
  28. package/node_modules/@noodle-borg/protocol/dist/v2/server.js +5 -5
  29. package/node_modules/@noodle-borg/runtime/dist/index.d.ts +1 -1
  30. package/node_modules/@noodle-borg/runtime/dist/operation-execution.d.ts +1 -1
  31. package/node_modules/@noodle-borg/runtime/dist/operation-execution.js +34 -6
  32. package/node_modules/@noodle-borg/runtime/dist/result.d.ts +18 -0
  33. package/node_modules/@noodle-borg/service/dist/routes/control-plane.js +1 -1
  34. package/node_modules/@noodle-borg/service/dist/service-resource-cleanup.js +1 -6
  35. package/node_modules/@noodle-borg/service/package.json +1 -1
  36. package/node_modules/@noodle-borg/transport-http/dist/client-identity.js +124 -0
  37. package/node_modules/@noodle-borg/transport-http/dist/identity-authorization.js +8 -8
  38. package/node_modules/@noodle-borg/transport-http/dist/request-capture.js +97 -10
  39. package/node_modules/@noodle-borg/transport-http/dist/serve-request.js +40 -10
  40. package/package.json +2 -2
@@ -3,7 +3,7 @@ import { executeResource } from '@noodle-borg/runtime';
3
3
  import { parseUriTemplate } from '@noodle-borg/uri-template';
4
4
  import { RESOURCE_NOT_FOUND } from '../error-codes.js';
5
5
  import { mapResourceContents, mapResourcesList, mapResourceTemplatesList } from '../mapping.js';
6
- import { estimateTokens, notify } from '../observation.js';
6
+ import { estimateTokens, executionErrorObservation, notify } from '../observation.js';
7
7
  export function registerResources(server, artifact, deps, context) {
8
8
  server.setRequestHandler(ListResourcesRequestSchema, () => mapResourcesList(artifact));
9
9
  server.setRequestHandler(ListResourceTemplatesRequestSchema, () => mapResourceTemplatesList(artifact));
@@ -19,7 +19,7 @@ export function registerResources(server, artifact, deps, context) {
19
19
  method: 'resources/read',
20
20
  resourceName: resource.name,
21
21
  outcome: 'mcp_error',
22
- errorKind: result.error.code,
22
+ ...executionErrorObservation(result.error),
23
23
  });
24
24
  throw new McpError(ErrorCode.InternalError, result.error.message);
25
25
  }
@@ -5,7 +5,7 @@ import { extractIntentCapture, intentCaptureEligible } from '../intent-capture.j
5
5
  import { extractElicitationResponses, toolUsesPortableInteractionArgument, } from '../interaction-envelope.js';
6
6
  import { findKnowledgeComponent, knowledgeToolsEnabled, runKnowledgeSearchTool, } from '../knowledge-tool.js';
7
7
  import { mapExecutionError, mapToolOutput, mapToolsList, redactWidgetLinkedOutput, } from '../mapping.js';
8
- import { estimateTokens, notify, } from '../observation.js';
8
+ import { estimateTokens, executionErrorObservation, notify, } from '../observation.js';
9
9
  import { evaluateToolAuthorization, TOOL_AUTHORIZATION_DENIED } from '../tool-authorization.js';
10
10
  import { runMcpToolInteraction } from '../tool-interaction.js';
11
11
  import { interactionUnavailableToolResult, stoppedToolResult } from '../tool-results.js';
@@ -148,10 +148,10 @@ export function registerTools(server, artifact, deps, context) {
148
148
  // The same split is the analytics two-tier taxonomy: tool_error vs mcp_error (ADR 0121).
149
149
  const outcome = mapExecutionError(result.error);
150
150
  if ('result' in outcome) {
151
- observe('tool_error', { errorKind: result.error.code });
151
+ observe('tool_error', executionErrorObservation(result.error));
152
152
  return outcome.result;
153
153
  }
154
- observe('mcp_error', { errorKind: result.error.code });
154
+ observe('mcp_error', executionErrorObservation(result.error));
155
155
  throw new McpError(outcome.error.code, outcome.error.message);
156
156
  });
157
157
  }
@@ -16,7 +16,23 @@ export interface ProtocolObservation {
16
16
  readonly intent?: IntentCaptureValue;
17
17
  /** Rough token-equivalent of the response payload (chars/4), the context-bloat gauge. */
18
18
  readonly outputTokensEst?: number;
19
+ /** Safe connector failure attribution projected from the execution error (#1309); scalars only. */
20
+ readonly connector?: ConnectorFailureAttribution;
19
21
  }
22
+ /**
23
+ * The analytics `errorKind` for an execution error: the stable code, refined with the allowlisted
24
+ * `reason` when one exists (`connector_error.timeout`). Analytics-only — wire mapping is unchanged.
25
+ */
26
+ export declare function executionErrorKind(error: {
27
+ readonly code: string;
28
+ readonly reason?: string;
29
+ }): string;
30
+ /** The observation slice carried by an execution error, ready to spread into an observe call. */
31
+ export declare function executionErrorObservation(error: {
32
+ readonly code: string;
33
+ readonly reason?: string;
34
+ readonly connector?: ConnectorFailureAttribution;
35
+ }): Pick<ProtocolObservation, 'errorKind' | 'connector'>;
20
36
  interface ObservationContext {
21
37
  readonly observe?: (observation: ProtocolObservation) => void;
22
38
  }
@@ -26,5 +42,6 @@ export declare function notify(context: ObservationContext, observation: Protoco
26
42
  * the response path serializes it once — wire framing/duplication is deliberately excluded. */
27
43
  export declare function estimateTokens(value: unknown): number;
28
44
  import type { IntentCaptureValue } from '@noodle-borg/module';
45
+ import type { ConnectorFailureAttribution } from '@noodle-borg/runtime';
29
46
  export {};
30
47
  //# sourceMappingURL=observation.d.ts.map
@@ -1,3 +1,17 @@
1
+ /**
2
+ * The analytics `errorKind` for an execution error: the stable code, refined with the allowlisted
3
+ * `reason` when one exists (`connector_error.timeout`). Analytics-only — wire mapping is unchanged.
4
+ */
5
+ export function executionErrorKind(error) {
6
+ return error.reason === undefined ? error.code : `${error.code}.${error.reason}`;
7
+ }
8
+ /** The observation slice carried by an execution error, ready to spread into an observe call. */
9
+ export function executionErrorObservation(error) {
10
+ return {
11
+ errorKind: executionErrorKind(error),
12
+ ...(error.connector === undefined ? {} : { connector: error.connector }),
13
+ };
14
+ }
1
15
  /** Report an observation; an observer bug must never break the request it watched. */
2
16
  export function notify(context, observation) {
3
17
  try {
@@ -8,7 +8,7 @@ import { extractIntentCapture, intentCaptureEligible } from '../intent-capture.j
8
8
  import { extractElicitationResponses, toolUsesPortableInteractionArgument, } from '../interaction-envelope.js';
9
9
  import { findKnowledgeComponent, knowledgeToolsEnabled, runKnowledgeSearchTool, } from '../knowledge-tool.js';
10
10
  import { mapExecutionError, mapPromptMessages, mapPromptsList, mapResourceContents, mapResourcesList, mapResourceTemplatesList, mapToolOutput, mapToolsList, redactWidgetLinkedOutput, } from '../mapping.js';
11
- import { estimateTokens, notify, } from '../observation.js';
11
+ import { estimateTokens, executionErrorObservation, notify, } from '../observation.js';
12
12
  import { buildProtocolRequestDeps } from '../request-deps.js';
13
13
  import { assertRequestStateBinding, RequestStateError, } from '../request-state.js';
14
14
  import { evaluateToolAuthorization } from '../tool-authorization.js';
@@ -310,10 +310,10 @@ function registerV2Tools(server, artifact, deps, context, era) {
310
310
  }
311
311
  const outcome = mapExecutionError(result.error);
312
312
  if ('result' in outcome) {
313
- observe('tool_error', { errorKind: result.error.code });
313
+ observe('tool_error', executionErrorObservation(result.error));
314
314
  return outcome.result;
315
315
  }
316
- observe('mcp_error', { errorKind: result.error.code });
316
+ observe('mcp_error', executionErrorObservation(result.error));
317
317
  throw new ProtocolError(outcome.error.code, outcome.error.message);
318
318
  });
319
319
  }
@@ -349,7 +349,7 @@ function registerV2Resources(server, artifact, deps, context, era, skill) {
349
349
  method: 'resources/read',
350
350
  resourceName: resource.name,
351
351
  outcome: 'mcp_error',
352
- errorKind: result.error.code,
352
+ ...executionErrorObservation(result.error),
353
353
  });
354
354
  throw new ProtocolError(-32603, result.error.message);
355
355
  }
@@ -390,7 +390,7 @@ function registerV2Prompts(server, artifact, deps, context) {
390
390
  }
391
391
  const result = await executePrompt(artifact, name, args ?? {}, deps);
392
392
  if (!result.ok) {
393
- observe('mcp_error', { errorKind: result.error.code });
393
+ observe('mcp_error', executionErrorObservation(result.error));
394
394
  throw new ProtocolError(-32603, result.error.message);
395
395
  }
396
396
  const mapped = mapPromptMessages(result.output, prompt.description);
@@ -16,7 +16,7 @@ export type { InvocationContext, InvocationContextPreferenceSource, InvocationLo
16
16
  export { type ManagedOriginResolutionResult, resolveManagedOrigins, } from './managed-origins.js';
17
17
  export { AllowAllPolicy } from './policy/allow-all.js';
18
18
  export type { PolicyContext, PolicyDecision, PolicyGate } from './policy/types.js';
19
- export { type ConfirmationActionReview, type ConfirmationPreparationResult, type ConfirmationReview, type ElicitationRequest, type ElicitationResponse, type ExecutionError, type ExecutionErrorCode, type ExecutionResult, type InteractiveExecutionResult, isConfirmationRequired, isInputRequired, isInputRequiredForConfirmation, type PreparedOperationAction, type PreparedToolContinuation, type ToolContinuation, type ToolPreparationContinuation, } from './result.js';
19
+ export { type ConfirmationActionReview, type ConfirmationPreparationResult, type ConfirmationReview, type ConnectorFailureAttribution, type ElicitationRequest, type ElicitationResponse, type ExecutionError, type ExecutionErrorCode, type ExecutionResult, type InteractiveExecutionResult, isConfirmationRequired, isInputRequired, isInputRequiredForConfirmation, type PreparedOperationAction, type PreparedToolContinuation, type ToolContinuation, type ToolPreparationContinuation, } from './result.js';
20
20
  export { splitResultMeta } from './result-meta.js';
21
21
  export { assertNoSecretValue, assertSchemaValue, COMPLETE_STATE_OPERATION, createStateConnector, InMemoryStateHandleStore, PATCH_STATE_OPERATION, READ_STATE_OPERATION, STATE_CONNECTOR_ID, STATE_CONNECTOR_VERSION, STATE_OPERATION_SIGNATURES, StateConnector, type StateHandleRecord, type StateHandleStore, type StateInput, type StateMutationInput, type StatePatchInput, } from './state-handles.js';
22
22
  export type { ToolDispatchContext, ToolDispatchDecision, ToolDispatchHook, } from './tool-dispatch.js';
@@ -29,6 +29,6 @@ export declare function prepareOperationAction(ref: ResolvedOperationRef | {
29
29
  }, argsAst: ExprMap, scope: EvalScope, deps: ExecuteDeps, argsPath: string, additionalOperationCount?: number): PreparedOperationActionResult;
30
30
  /** @internal Shared with the suspension-aware flow executor. */
31
31
  export declare function createHost(toolName: string, deps: ExecuteDeps, policy: PolicyGate, env: Record<string, string>, callStack: string[], beforeDispatch?: ToolDispatchHook): ConnectorCallHost;
32
- export declare function fail(code: ExecutionError['code'], message: string, pathOrDetails?: string | Pick<ExecutionError, 'reason' | 'fix' | 'next'>): ExecutionResult;
32
+ export declare function fail(code: ExecutionError['code'], message: string, pathOrDetails?: string | Pick<ExecutionError, 'reason' | 'fix' | 'next' | 'connector'>): ExecutionResult;
33
33
  export {};
34
34
  //# sourceMappingURL=operation-execution.d.ts.map
@@ -189,14 +189,32 @@ async function invokeOperation(ref, args, sig, toolName, deps, policy, host, env
189
189
  operation: ref.operation,
190
190
  ...failureDetails,
191
191
  });
192
- if (failureDetails.category === 'response_too_large') {
193
- return fail('connector_error', `connector failed for operation "${ref.operation}"`, {
194
- reason: 'response_too_large',
195
- });
196
- }
192
+ // Attribution keeps the sanitized classification (never the excerpt) so analytics can name
193
+ // the failing connector/operation/stage while the wire result stays generic (#1309).
194
+ const status = statusClass(failureDetails.status);
195
+ return fail('connector_error', `connector failed for operation "${ref.operation}"`, {
196
+ ...(failureDetails.category === undefined ? {} : { reason: failureDetails.category }),
197
+ connector: {
198
+ connectorId: ref.connectorId,
199
+ connectorVersion: ref.connectorVersion,
200
+ operation: ref.operation,
201
+ ...(failureDetails.category === undefined ? {} : { category: failureDetails.category }),
202
+ ...(status === undefined ? {} : { statusClass: status }),
203
+ ...(failureDetails.attempts === undefined ? {} : { attempts: failureDetails.attempts }),
204
+ ...(failureDetails.retryable === undefined
205
+ ? {}
206
+ : { retryable: failureDetails.retryable }),
207
+ },
208
+ });
197
209
  }
198
210
  // Normalize connector failures; never surface backend internals or credentials.
199
- return fail('connector_error', `connector failed for operation "${ref.operation}"`);
211
+ return fail('connector_error', `connector failed for operation "${ref.operation}"`, {
212
+ connector: {
213
+ connectorId: ref.connectorId,
214
+ connectorVersion: ref.connectorVersion,
215
+ operation: ref.operation,
216
+ },
217
+ });
200
218
  }
201
219
  if (customerRoute !== undefined) {
202
220
  const cloned = cloneRoutedOutput(output, customerRoute.baseUrl);
@@ -278,6 +296,16 @@ function hostCallErrorSnapshot(value) {
278
296
  ? hostCallErrors.get(value)
279
297
  : undefined;
280
298
  }
299
+ /** Upstream status class for retained attribution; the exact status stays out of analytics. */
300
+ function statusClass(status) {
301
+ if (status === undefined)
302
+ return undefined;
303
+ if (status >= 500 && status <= 599)
304
+ return '5xx';
305
+ if (status >= 400 && status <= 499)
306
+ return '4xx';
307
+ return undefined;
308
+ }
281
309
  export function fail(code, message, pathOrDetails) {
282
310
  if (typeof pathOrDetails === 'string') {
283
311
  return { ok: false, error: { code, message, path: pathOrDetails } };
@@ -1,7 +1,23 @@
1
1
  import type { CredentialProfile } from '@noodle-borg/compiler';
2
+ import type { ConnectorFailureCategory } from './connector/types.js';
2
3
  import type { CustomerRouteBinding } from './customer-routing.js';
3
4
  /** Why a tool call could not be fulfilled. Protocol and embedded adapters map this internal contract. */
4
5
  export type ExecutionErrorCode = 'shape_only_artifact' | 'unknown_tool' | 'unknown_resource' | 'unknown_prompt' | 'unsupported_fulfilment' | 'connector_unavailable' | 'connector_route_unavailable' | 'credential_unavailable' | 'signature_drift' | 'policy_denied' | 'policy_error' | 'dispatch_denied' | 'usage_limit_exceeded' | 'duplicate_execution_suppressed' | 'execution_admission_error' | 'execution_cancelled' | 'arg_invalid' | 'invalid_continuation' | 'invalid_elicitation_flow' | 'invalid_confirmation_flow' | 'expression_error' | 'circular_call_dependency' | 'connector_error' | 'output_invalid';
6
+ /**
7
+ * Safe connector failure attribution for operator analytics (issue #1309): platform identifiers and
8
+ * bounded classification only — never an upstream message, response body, credential, or exact URL.
9
+ * Adapters must keep this off every model-facing wire channel.
10
+ */
11
+ export interface ConnectorFailureAttribution {
12
+ readonly connectorId: string;
13
+ readonly connectorVersion: string;
14
+ readonly operation: string;
15
+ readonly category?: ConnectorFailureCategory;
16
+ /** Upstream HTTP status class; the exact status is deliberately not retained. */
17
+ readonly statusClass?: '4xx' | '5xx';
18
+ readonly attempts?: number;
19
+ readonly retryable?: boolean;
20
+ }
5
21
  /** A typed execution failure. `path` locates the offending field/expression when applicable and
6
22
  * never carries a sensitive value. */
7
23
  export interface ExecutionError {
@@ -10,6 +26,8 @@ export interface ExecutionError {
10
26
  readonly path?: string;
11
27
  /** Stable, allowlisted machine-readable detail. Never an upstream error message. */
12
28
  readonly reason?: string;
29
+ /** Present on connector failures; analytics-only, never serialized to a client. */
30
+ readonly connector?: ConnectorFailureAttribution;
13
31
  /** Safe monthly-allowance reset instant, present only for commercial usage denial. */
14
32
  readonly resetAt?: string;
15
33
  /** Safe remediation text intended for a developer or agent. */
@@ -1,4 +1,5 @@
1
1
  import { cspFaultsInManifest } from '@noodle-borg/compiler';
2
+ import { ensurePersonalWorkspace } from '@noodle-borg/control-plane/portable';
2
3
  import { DEVELOPER_CLI_PATH } from '@noodle-borg/developer-mcp';
3
4
  import { normalizeServerVersion } from '@noodle-borg/module';
4
5
  import { noopLogger } from '@noodle-borg/transport-http';
@@ -6,7 +7,6 @@ import { deployRequestSchema, formatWireError, } from '@noodle-borg/wire-contrac
6
7
  import { authorizeDeveloperGrant, } from '../auth/developer-grant-guard.js';
7
8
  import { baseFromRequest, readDeployBody, sendForbidden, sendJson, sendUnauthorized, } from '../http-util.js';
8
9
  import { endpointUrlOptionsForOrg } from '../mcp-public-routing.js';
9
- import { ensurePersonalWorkspace } from '../personal-workspace.js';
10
10
  import { manifestUsesUserRoot } from '../registry-access.js';
11
11
  import { isIdentityAccessMode } from './access-mode.js';
12
12
  import { validateDeployIdempotency } from './deploy-idempotency.js';
@@ -1,9 +1,5 @@
1
1
  /** Close every boot-owned resource, preserving all failures without skipping later cleanup. */
2
2
  export async function closeServiceResources(input) {
3
- if (input.retentionTimer !== undefined)
4
- clearInterval(input.retentionTimer);
5
- if (input.intentRetentionTimer !== undefined)
6
- clearInterval(input.intentRetentionTimer);
7
3
  if (input.welcomeEmailTimer !== undefined)
8
4
  clearInterval(input.welcomeEmailTimer);
9
5
  if (input.feedbackOperationsTimer !== undefined)
@@ -14,8 +10,7 @@ export async function closeServiceResources(input) {
14
10
  if (moduleHost !== undefined) {
15
11
  await captureCleanupError(errors, () => moduleHost.dispose());
16
12
  }
17
- await captureCleanupError(errors, () => input.requestEventBuffer.close());
18
- await captureCleanupError(errors, () => input.intentEventBuffer.close());
13
+ await captureCleanupError(errors, () => input.telemetry.dispose());
19
14
  const postgresPool = input.postgresPool;
20
15
  if (postgresPool !== undefined) {
21
16
  await captureCleanupError(errors, () => postgresPool.close());
@@ -39,7 +39,7 @@
39
39
  "dependencies": {
40
40
  "@modelcontextprotocol/sdk": "^1.29.0",
41
41
  "@noodle-borg/admission-limits": "0.0.0",
42
- "@noodle-borg/agent-kit": "0.84.0",
42
+ "@noodle-borg/agent-kit": "0.84.1",
43
43
  "@noodle-borg/app-package": "0.0.0",
44
44
  "@noodle-borg/assistant-gateway": "0.0.0",
45
45
  "@noodle-borg/auth": "0.0.0",
@@ -0,0 +1,124 @@
1
+ import { CLIENT_FAMILY_OTHER, CLIENT_FAMILY_UNKNOWN } from '@noodle-borg/module';
2
+ /**
3
+ * Safe client attribution for request analytics (issue #1309): bounded projections of MCP
4
+ * `clientInfo` and of the transport user agent, plus the best-effort session→clientInfo memo that
5
+ * lets legacy-era requests after `initialize` keep their client identity. Nothing here ever retains
6
+ * a raw user-agent string, and every retained value is length-bounded and control-character free.
7
+ */
8
+ const MAX_CLIENT_NAME = 128;
9
+ const MAX_CLIENT_VERSION = 64;
10
+ const MAX_FAMILY = 32;
11
+ /**
12
+ * Known HTTP client user agents, matched case-insensitively against the user-agent prefix tokens.
13
+ * A closed table keeps the retained cardinality bounded: anything else becomes `other`, an absent
14
+ * user agent becomes `unknown`. Order matters — first match wins.
15
+ */
16
+ const KNOWN_UA_FAMILIES = [
17
+ [/claude/i, 'claude'],
18
+ [/anthropic/i, 'anthropic-sdk'],
19
+ [/openai/i, 'openai'],
20
+ [/mcp-inspector|inspector/i, 'mcp-inspector'],
21
+ [/undici/i, 'undici'],
22
+ [/node-fetch/i, 'node-fetch'],
23
+ [/\bnode\b|nodejs/i, 'node'],
24
+ [/\bbun\b/i, 'bun'],
25
+ [/\bdeno\b/i, 'deno'],
26
+ [/python-httpx|\bhttpx\b/i, 'python-httpx'],
27
+ [/python-requests/i, 'python-requests'],
28
+ [/python-urllib|python/i, 'python'],
29
+ [/axios/i, 'axios'],
30
+ [/\bgot\b/i, 'got'],
31
+ [/curl/i, 'curl'],
32
+ [/wget/i, 'wget'],
33
+ [/postman/i, 'postman'],
34
+ [/insomnia/i, 'insomnia'],
35
+ [/go-http-client/i, 'go-http-client'],
36
+ [/okhttp/i, 'okhttp'],
37
+ [/java/i, 'java'],
38
+ [/mozilla|chrome|safari|firefox|edg/i, 'browser'],
39
+ ];
40
+ /** Bound a wire-supplied client identity scalar: printable, trimmed, and length-capped. */
41
+ export function boundedClientScalar(value, max) {
42
+ if (value === undefined)
43
+ return undefined;
44
+ let cleaned = '';
45
+ for (const char of value) {
46
+ const code = char.codePointAt(0) ?? 0;
47
+ if (code >= 0x20 && code !== 0x7f)
48
+ cleaned += char; // strip C0 controls and DEL
49
+ }
50
+ cleaned = cleaned.trim();
51
+ if (cleaned.length === 0)
52
+ return undefined;
53
+ return cleaned.length > max ? cleaned.slice(0, max) : cleaned;
54
+ }
55
+ export function boundedClientName(value) {
56
+ return boundedClientScalar(value, MAX_CLIENT_NAME);
57
+ }
58
+ export function boundedClientVersion(value) {
59
+ return boundedClientScalar(value, MAX_CLIENT_VERSION);
60
+ }
61
+ /**
62
+ * The bounded client family for one request. `clientInfo.name` wins (its sanitized token — the name
63
+ * itself is already retained, so the family adds no cardinality); otherwise the user agent maps
64
+ * through the closed table above; `other` for an unmatched agent, `unknown` for none at all.
65
+ */
66
+ export function clientFamily(clientName, userAgent) {
67
+ if (clientName !== undefined) {
68
+ const token = clientName
69
+ .toLowerCase()
70
+ .replace(/[^a-z0-9._-]+/g, '-')
71
+ .replace(/^-+|-+$/g, '')
72
+ .slice(0, MAX_FAMILY);
73
+ if (token.length > 0)
74
+ return token;
75
+ }
76
+ if (userAgent === undefined || userAgent.trim().length === 0)
77
+ return CLIENT_FAMILY_UNKNOWN;
78
+ for (const [pattern, family] of KNOWN_UA_FAMILIES) {
79
+ if (pattern.test(userAgent))
80
+ return family;
81
+ }
82
+ return CLIENT_FAMILY_OTHER;
83
+ }
84
+ /**
85
+ * Bounded, best-effort LRU memo from an analytics session id to the `initialize` client identity,
86
+ * keyed per tenant so one tenant's session ids can never resolve another's client names. Purely an
87
+ * attribution aid: entries are non-authoritative, evicted oldest-first, and never required.
88
+ */
89
+ export class SessionClientMemo {
90
+ #entries = new Map();
91
+ #cap;
92
+ constructor(cap = 2048) {
93
+ this.#cap = cap;
94
+ }
95
+ remember(key, identity) {
96
+ if (identity.clientName === undefined && identity.clientVersion === undefined)
97
+ return;
98
+ if (this.#entries.has(key))
99
+ this.#entries.delete(key);
100
+ this.#entries.set(key, identity);
101
+ if (this.#entries.size > this.#cap) {
102
+ const oldest = this.#entries.keys().next().value;
103
+ if (oldest !== undefined)
104
+ this.#entries.delete(oldest);
105
+ }
106
+ }
107
+ recall(key) {
108
+ const found = this.#entries.get(key);
109
+ if (found !== undefined) {
110
+ // Refresh recency so active sessions survive eviction pressure.
111
+ this.#entries.delete(key);
112
+ this.#entries.set(key, found);
113
+ }
114
+ return found;
115
+ }
116
+ get size() {
117
+ return this.#entries.size;
118
+ }
119
+ }
120
+ /** Tenant-scoped memo key; the org always participates so keys cannot collide across tenants. */
121
+ export function sessionClientKey(org, app, env, sessionId) {
122
+ return `${org}/${app ?? ''}/${env ?? ''}#${sessionId}`;
123
+ }
124
+ //# sourceMappingURL=client-identity.js.map
@@ -11,12 +11,12 @@ export async function authorizeIdentityMode(req, res, auth) {
11
11
  const token = bearerToken(req);
12
12
  if (token === null || auth.verifyOwnerToken === undefined) {
13
13
  sendUnauthorized(res, challenge);
14
- return { allow: false };
14
+ return { allow: false, reason: token === null ? 'missing_token' : 'verifier_unavailable' };
15
15
  }
16
16
  const verification = await auth.verifyOwnerToken(token, canonicalResourceUrl(req, auth));
17
17
  if (verification === null) {
18
18
  sendUnauthorized(res, challenge);
19
- return { allow: false };
19
+ return { allow: false, reason: 'token_rejected' };
20
20
  }
21
21
  const { caller: identity } = verification;
22
22
  if (identity.identityKind === 'service') {
@@ -36,18 +36,18 @@ export async function authorizeIdentityMode(req, res, auth) {
36
36
  if (auth.accessMode === 'customers') {
37
37
  if (identity.identityKind !== 'customer') {
38
38
  sendUnauthorized(res, challenge);
39
- return { allow: false };
39
+ return { allow: false, reason: 'identity_not_customer' };
40
40
  }
41
41
  return { allow: true, caller: identity, ...privateCustomerContext };
42
42
  }
43
43
  if (auth.accessMode === 'owner-only') {
44
44
  if (auth.ownerSubject === undefined) {
45
45
  sendUnauthorized(res, challenge);
46
- return { allow: false };
46
+ return { allow: false, reason: 'owner_unconfigured' };
47
47
  }
48
48
  if (identity.subject !== auth.ownerSubject) {
49
49
  sendForbidden(res);
50
- return { allow: false };
50
+ return { allow: false, reason: 'owner_mismatch' };
51
51
  }
52
52
  return { allow: true, caller: identity, ...privateCustomerContext };
53
53
  }
@@ -65,7 +65,7 @@ export async function authorizeIdentityMode(req, res, auth) {
65
65
  });
66
66
  if (membership?.allowed !== true) {
67
67
  sendForbidden(res);
68
- return { allow: false };
68
+ return { allow: false, reason: 'org_membership_denied' };
69
69
  }
70
70
  }
71
71
  return { allow: true, caller: identity, ...privateCustomerContext };
@@ -90,12 +90,12 @@ export async function authorizeMixedMode(req, res, auth) {
90
90
  const challenge = protectedResourceMetadataUrl(req, auth);
91
91
  if (auth.verifyOwnerToken === undefined) {
92
92
  sendUnauthorized(res, challenge);
93
- return { allow: false };
93
+ return { allow: false, reason: 'verifier_unavailable' };
94
94
  }
95
95
  const verification = await auth.verifyOwnerToken(token, canonicalResourceUrl(req, auth));
96
96
  if (verification === null) {
97
97
  sendUnauthorized(res, challenge);
98
- return { allow: false };
98
+ return { allow: false, reason: 'token_rejected' };
99
99
  }
100
100
  return {
101
101
  allow: true,
@@ -1,3 +1,13 @@
1
+ import { boundedClientName, boundedClientVersion, clientFamily, SessionClientMemo, sessionClientKey, } from './client-identity.js';
2
+ /**
3
+ * Best-effort per-process memo from a legacy analytics session id to its `initialize` clientInfo, so
4
+ * legacy tool calls keep client attribution when the client supplies `Mcp-Session-Id` (#1309).
5
+ */
6
+ let sessionClientMemo = new SessionClientMemo();
7
+ /** Test seam: drop all memoized session client identities. */
8
+ export function resetSessionClientMemo() {
9
+ sessionClientMemo = new SessionClientMemo();
10
+ }
1
11
  /** JSON-RPC methods that are protocol discovery/chatter, excluded from usage metrics (ADR 0121). */
2
12
  const DISCOVERY_METHODS = new Set([
3
13
  'server/discover',
@@ -33,6 +43,25 @@ export function emitRequestEvent(input) {
33
43
  const failed = res.statusCode >= 400;
34
44
  const toolName = observed?.toolName ?? (method === 'tools/call' ? rpcTargetName(first, method) : undefined);
35
45
  const errorKind = observed?.errorKind ?? (failed ? `http_${res.statusCode}` : undefined);
46
+ // Client identity: the request's own clientInfo wins; a legacy request that supplies a session id
47
+ // falls back to the identity memoized from that session's `initialize` (#1309). Values are bounded.
48
+ let clientName = boundedClientName(modern?.clientName ?? init?.clientName);
49
+ let clientVersion = boundedClientVersion(modern?.clientVersion ?? init?.clientVersion);
50
+ if (sessionId !== undefined) {
51
+ const key = sessionClientKey(org, tenant?.app ?? auth.app, tenant?.env ?? auth.environment, sessionId);
52
+ if (method === 'initialize' && clientName !== undefined) {
53
+ sessionClientMemo.remember(key, {
54
+ clientName,
55
+ ...(clientVersion === undefined ? {} : { clientVersion }),
56
+ });
57
+ }
58
+ else if (clientName === undefined) {
59
+ const recalled = sessionClientMemo.recall(key);
60
+ clientName = recalled?.clientName;
61
+ clientVersion ??= recalled?.clientVersion;
62
+ }
63
+ }
64
+ const family = clientFamily(clientName, header(req, 'user-agent'));
36
65
  const event = {
37
66
  org,
38
67
  ...(tenant !== undefined ? { app: tenant.app, env: tenant.env } : {}),
@@ -43,16 +72,9 @@ export function emitRequestEvent(input) {
43
72
  requestId,
44
73
  ...(sessionId !== undefined ? { sessionId } : {}),
45
74
  sessionSource: sessionId !== undefined ? 'mcp' : 'none',
46
- ...(modern?.clientName !== undefined
47
- ? { clientName: modern.clientName }
48
- : init?.clientName !== undefined
49
- ? { clientName: init.clientName }
50
- : {}),
51
- ...(modern?.clientVersion !== undefined
52
- ? { clientVersion: modern.clientVersion }
53
- : init?.clientVersion !== undefined
54
- ? { clientVersion: init.clientVersion }
55
- : {}),
75
+ ...(clientName !== undefined ? { clientName } : {}),
76
+ ...(clientVersion !== undefined ? { clientVersion } : {}),
77
+ clientFamily: family,
56
78
  ...(auth.accessMode !== undefined ? { accessMode: auth.accessMode } : {}),
57
79
  subjectKind: subject !== undefined ? 'authenticated' : 'anonymous',
58
80
  method,
@@ -63,9 +85,12 @@ export function emitRequestEvent(input) {
63
85
  outcome: observed?.outcome ?? (failed ? 'mcp_error' : 'ok'),
64
86
  ...(errorKind !== undefined ? { errorKind } : {}),
65
87
  durationMs: Date.now() - startedAt,
88
+ ...(input.timing?.queueMs !== undefined ? { queueMs: input.timing.queueMs } : {}),
89
+ ...(input.timing?.execMs !== undefined ? { execMs: input.timing.execMs } : {}),
66
90
  ...(observed?.outputTokensEst !== undefined
67
91
  ? { outputTokensEst: observed.outputTokensEst }
68
92
  : {}),
93
+ ...connectorDetails(observed),
69
94
  };
70
95
  try {
71
96
  capture(event);
@@ -74,6 +99,68 @@ export function emitRequestEvent(input) {
74
99
  // Analytics is strictly best-effort; a capture bug must never surface to the caller.
75
100
  }
76
101
  }
102
+ /** Project the observation's safe connector attribution into the scalar-only `details` bag. */
103
+ function connectorDetails(observed) {
104
+ const connector = observed?.connector;
105
+ if (connector === undefined)
106
+ return {};
107
+ return {
108
+ details: {
109
+ connectorId: connector.connectorId,
110
+ connectorVersion: connector.connectorVersion,
111
+ connectorOperation: connector.operation,
112
+ ...(connector.category !== undefined ? { connectorCategory: connector.category } : {}),
113
+ ...(connector.statusClass !== undefined
114
+ ? { connectorStatusClass: connector.statusClass }
115
+ : {}),
116
+ ...(connector.attempts !== undefined ? { connectorAttempts: connector.attempts } : {}),
117
+ ...(connector.retryable !== undefined ? { connectorRetryable: connector.retryable } : {}),
118
+ },
119
+ };
120
+ }
121
+ /**
122
+ * Reason a front-door denial (identity auth, admission, tool authorization) was sent before any
123
+ * protocol handler ran. Bounded reason tokens compose the analytics `errorKind` (#1309).
124
+ */
125
+ export function emitDeniedRequestEvent(input) {
126
+ const { req, auth, tenant, target, parsed, startedAt, requestId, errorKind } = input;
127
+ const capture = auth.captureRequestEvent;
128
+ if (capture === undefined)
129
+ return;
130
+ const org = tenant?.org ?? auth.org;
131
+ if (org === undefined)
132
+ return;
133
+ const first = Array.isArray(parsed) ? parsed[0] : parsed;
134
+ const method = first === undefined ? 'unknown' : rpcMethod(first);
135
+ const toolName = method === 'tools/call' ? rpcTargetName(first, method) : undefined;
136
+ const modern = modernRequestInfo(first);
137
+ const sdkProtocolVersion = modern?.protocolVersion ?? header(req, 'mcp-protocol-version');
138
+ const event = {
139
+ org,
140
+ ...(tenant !== undefined ? { app: tenant.app, env: tenant.env } : {}),
141
+ ...(auth.deploymentId !== undefined ? { deploymentId: auth.deploymentId } : {}),
142
+ serverVersion: target.artifact.server.version,
143
+ ...(sdkProtocolVersion !== undefined ? { sdkProtocolVersion } : {}),
144
+ ...(first !== undefined ? { protocolEra: modern === undefined ? 'legacy' : 'modern' } : {}),
145
+ requestId,
146
+ sessionSource: 'none',
147
+ clientFamily: clientFamily(undefined, header(req, 'user-agent')),
148
+ ...(auth.accessMode !== undefined ? { accessMode: auth.accessMode } : {}),
149
+ subjectKind: 'anonymous',
150
+ method,
151
+ kind: 'usage',
152
+ ...(toolName !== undefined ? { toolName } : {}),
153
+ outcome: 'mcp_error',
154
+ errorKind,
155
+ durationMs: Date.now() - startedAt,
156
+ };
157
+ try {
158
+ capture(event);
159
+ }
160
+ catch {
161
+ // Analytics is strictly best-effort.
162
+ }
163
+ }
77
164
  /** Emit validated intent observations into their separate operator stream. */
78
165
  export function emitIntentEvents(input) {
79
166
  const { req, target, auth, tenant, parsed, observations, requestId } = input;