@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.
- package/dist/metrics-render.d.ts +8 -0
- package/dist/metrics-render.d.ts.map +1 -1
- package/dist/metrics-render.js +7 -1
- package/dist/metrics-render.js.map +1 -1
- package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/control-plane/dist/personal-workspace.d.ts +7 -0
- package/node_modules/@noodle-borg/{service → control-plane}/dist/personal-workspace.js +1 -2
- package/node_modules/@noodle-borg/control-plane/dist/portable.d.ts +1 -0
- package/node_modules/@noodle-borg/control-plane/dist/portable.js +1 -0
- package/node_modules/@noodle-borg/module/dist/contract.d.ts +0 -64
- package/node_modules/@noodle-borg/module/dist/contract.js +3 -2
- package/node_modules/@noodle-borg/module/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/module/dist/index.js +1 -0
- package/node_modules/@noodle-borg/module/dist/intent.d.ts +1 -1
- package/node_modules/@noodle-borg/module/dist/request-analytics.d.ts +80 -0
- package/node_modules/@noodle-borg/module/dist/request-analytics.js +10 -0
- package/node_modules/@noodle-borg/observability/dist/intent-capture.js +4 -0
- package/node_modules/@noodle-borg/observability/dist/request-event-buffer.js +4 -0
- package/node_modules/@noodle-borg/observability/dist/request-event-metrics.js +15 -0
- package/node_modules/@noodle-borg/observability/dist/request-events-postgres.js +19 -3
- package/node_modules/@noodle-borg/observability/dist/request-events.js +3 -0
- package/node_modules/@noodle-borg/observability/dist/runtime.js +61 -1
- package/node_modules/@noodle-borg/protocol/dist/handlers/prompts.js +2 -2
- package/node_modules/@noodle-borg/protocol/dist/handlers/resources.js +2 -2
- package/node_modules/@noodle-borg/protocol/dist/handlers/tools.js +3 -3
- package/node_modules/@noodle-borg/protocol/dist/observation.d.ts +17 -0
- package/node_modules/@noodle-borg/protocol/dist/observation.js +14 -0
- package/node_modules/@noodle-borg/protocol/dist/v2/server.js +5 -5
- package/node_modules/@noodle-borg/runtime/dist/index.d.ts +1 -1
- package/node_modules/@noodle-borg/runtime/dist/operation-execution.d.ts +1 -1
- package/node_modules/@noodle-borg/runtime/dist/operation-execution.js +34 -6
- package/node_modules/@noodle-borg/runtime/dist/result.d.ts +18 -0
- package/node_modules/@noodle-borg/service/dist/routes/control-plane.js +1 -1
- package/node_modules/@noodle-borg/service/dist/service-resource-cleanup.js +1 -6
- package/node_modules/@noodle-borg/service/package.json +1 -1
- package/node_modules/@noodle-borg/transport-http/dist/client-identity.js +124 -0
- package/node_modules/@noodle-borg/transport-http/dist/identity-authorization.js +8 -8
- package/node_modules/@noodle-borg/transport-http/dist/request-capture.js +97 -10
- package/node_modules/@noodle-borg/transport-http/dist/serve-request.js +40 -10
- 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
|
-
|
|
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',
|
|
151
|
+
observe('tool_error', executionErrorObservation(result.error));
|
|
152
152
|
return outcome.result;
|
|
153
153
|
}
|
|
154
|
-
observe('mcp_error',
|
|
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',
|
|
313
|
+
observe('tool_error', executionErrorObservation(result.error));
|
|
314
314
|
return outcome.result;
|
|
315
315
|
}
|
|
316
|
-
observe('mcp_error',
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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.
|
|
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.
|
|
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
|
-
...(
|
|
47
|
-
|
|
48
|
-
|
|
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;
|