mcpeye 0.1.3 → 0.1.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b65ed38eb2861006ebcd79b5592922e1281d71fce912dec5543b01cba961244
4
- data.tar.gz: 3184681a41e2669cf3b9c240acad0bd4418d811fdbc97bb988ac82b5533ec42b
3
+ metadata.gz: 3d50e6d30ef0e2cf6796b7b7b67397c40c11290f289152d124c3ff5417ff6150
4
+ data.tar.gz: 23729136fae41b35dcf3152354d59fd5e2753d7429831b1f474bc01de56c6d5a
5
5
  SHA512:
6
- metadata.gz: fe8f68cdae22753dd5ff53e284e0c493e313e4f9dca104e50f3a9be6d5ae6147d19b8215f0f288989ae8e30c2e3c4134d02a85ee4cbd12cebc2f07241e9dd2e8
7
- data.tar.gz: dd3ca7ff1636b5a7b1cd424c2a854e095cddb331c199c4c7775737a0f2e7bd88a1cf861e5b59b112cf04a114cfb424c565173145356829dd6c735447b0008d68
6
+ metadata.gz: 84b70bcbed2a41a80a198b44e32b4f84eaa7f753aa3701e708eebc7210e78833dd6a1d67f471ac7e62aa7f1326dd96a75eaa9b3e8946465e78432e2554ee67e7
7
+ data.tar.gz: a75cc956872617ae3da0400b44b8a54a1f3e2bb7b265ca6e4dad5fb5ae50f453d24ec3256cfda971563091ff77f1a4b33059d6bfc73b378745530ffdefd015a4
data/README.md CHANGED
@@ -165,9 +165,19 @@ MCPEYE = Mcpeye.track(
165
165
  ENV.fetch("MCPEYE_PROJECT_ID"),
166
166
  ingest_url: ENV.fetch("MCPEYE_INGEST_URL", "http://localhost:3001"),
167
167
  ingest_secret: ENV["MCPEYE_INGEST_SECRET"],
168
- # Per-request identity: evaluated once per flush, so a thread/request-local
169
- # value is attributed correctly. Pass an OPAQUE, non-PII id (coerced to a string).
170
- identify: -> { { userId: Current.user_id&.to_s, client: Current.client, serverVersion: MyApp::VERSION } },
168
+ # End-user identity. `userId`/`userEmail` are resolved PER CALL on the request
169
+ # thread (so a thread/request-local value like Rails Current / RequestStore is
170
+ # attributed correctly even on a multi-user / stateless server); client/serverVersion
171
+ # are read per flush. Pass an OPAQUE, stable userId. This is what powers the
172
+ # dashboard's search-by-id/email — without it, sessions read "user not identified".
173
+ identify: lambda {
174
+ {
175
+ userId: Current.user_id&.to_s, # who the end user is (for search)
176
+ userEmail: Current.user_email, # human-readable, optional (PII you store)
177
+ client: Current.client, # process/connection-level
178
+ serverVersion: MyApp::VERSION,
179
+ }
180
+ },
171
181
  # Drop your own domain-sensitive fields on top of the built-in denylist:
172
182
  denylist_fields: %w[ssn account_number],
173
183
  # Forward diagnostics into your logger instead of stderr:
@@ -175,6 +185,13 @@ MCPEYE = Mcpeye.track(
175
185
  )
176
186
  ```
177
187
 
188
+ > **Multi-user / stateless servers (e.g. a streamable-HTTP MCP).** Because `identify`
189
+ > runs per call on the request thread, set your per-request user context BEFORE the
190
+ > tool dispatches (e.g. in middleware: `RequestStore.store[:current_user_id] = ...`)
191
+ > and read it in `identify` (`RequestStore.store[:current_user_id]`). Each captured
192
+ > call is then attributed to the right user, even though one flushed batch may mix
193
+ > users.
194
+
178
195
  ### Puma / Unicorn (forking servers)
179
196
 
180
197
  A thread does **not** survive `fork`, so in a clustered server start the flush
@@ -24,7 +24,12 @@ module Mcpeye
24
24
  #
25
25
  # The wire shape matches @mcpeye/core's IngestPayload exactly (and is
26
26
  # byte-compatible with the TS and Python SDKs):
27
- # { projectId, identity: { userId?, client?, serverVersion? }, events: [...] }
27
+ # { projectId, identity: { userId?, userEmail?, client?, serverVersion? },
28
+ # events: [{ ..., userId?, userEmail? }] }
29
+ # Each event also carries its OWN userId/userEmail, resolved PER CALL on the
30
+ # request thread (see #resolve_call_identity) — so attribution is correct on a
31
+ # multi-user server where one flushed batch mixes users; the batch identity is the
32
+ # fallback.
28
33
  #
29
34
  # Prime directive: this NEVER raises into, or alters, the host MCP server. The
30
35
  # only intentional raise is an empty `project_id` at construction (fail loud
@@ -182,10 +187,15 @@ module Mcpeye
182
187
  # ingest_secret — shared secret sent as x-mcpeye-secret.
183
188
  # Defaults to ENV["MCPEYE_INGEST_SECRET"].
184
189
  # redact — when true (default) scrub arguments/result/intent/error client-side.
185
- # identity — static Hash { userId:, client:, serverVersion: }.
186
- # identify — optional callable evaluated once per flush, returning the
187
- # identity Hash (per-request/thread-local attribution). A
188
- # raising identify yields {} for that flush, never breaks it.
190
+ # identity — static Hash { userId:, userEmail:, client:, serverVersion: }
191
+ # (batch-level fallback).
192
+ # identify — optional callable for dynamic attribution. Evaluated PER CALL
193
+ # on the request thread for userId/userEmail (correct per-event
194
+ # attribution on a multi-user server) AND once per flush for the
195
+ # batch identity. Return { userId:, userEmail:, client:,
196
+ # serverVersion: }. A raising identify yields no identity for that
197
+ # call/flush, never breaks the host. Example:
198
+ # identify: -> { { userId: RequestStore.store[:current_user_id] } }
189
199
  # flush_threshold — eager-flush buffer size (default 20).
190
200
  # flush_interval — background flush interval in seconds (default nil = no
191
201
  # background thread; stay zero-thread unless asked).
@@ -519,6 +529,14 @@ module Mcpeye
519
529
  event["errorMessage"] = guard_error_message(error_message.to_s) unless error_message.nil?
520
530
  # Only a non-blank intent is recorded (whitespace-only is dropped, TS/Python parity).
521
531
  event["intent"] = guard_error_message(intent.to_s) if intent && !intent.to_s.strip.empty?
532
+ # Per-CALL end-user identity. build_event runs on the caller's (request) thread
533
+ # — via the official call_tool hook or the wrap proc — so `identify` is evaluated
534
+ # where the host's per-request user context is live (unlike the per-flush batch
535
+ # identity, which runs on the flush thread). This is what makes attribution
536
+ # correct on a multi-user server. Per-event values win over the batch identity.
537
+ uid, uemail = resolve_call_identity
538
+ event["userId"] = uid if uid
539
+ event["userEmail"] = uemail if uemail
522
540
  event
523
541
  end
524
542
 
@@ -649,6 +667,30 @@ module Mcpeye
649
667
  {}
650
668
  end
651
669
 
670
+ # Per-CALL end-user identity for ONE event, resolved on the caller's (request)
671
+ # thread. Uses `identify` (evaluated per call) when given, else the static
672
+ # `identity`. Returns [user_id, user_email] as normalized Strings or nils.
673
+ # Fail-open: a raising identify yields [nil, nil], never into the host call.
674
+ def resolve_call_identity
675
+ raw = @identify ? @identify.call : @identity
676
+ raw = {} unless raw.is_a?(Hash)
677
+ [norm_ident(raw[:userId] || raw["userId"]), norm_ident(raw[:userEmail] || raw["userEmail"])]
678
+ rescue StandardError => e
679
+ @on_error.call(e)
680
+ [nil, nil]
681
+ end
682
+
683
+ # Coerce an identity value to a trimmed String <=256 chars, or nil if blank.
684
+ # Coerces a non-String id (e.g. an Integer user id) like normalize_identity.
685
+ def norm_ident(value)
686
+ return nil if value.nil?
687
+
688
+ s = value.to_s.strip
689
+ return nil if s.empty?
690
+
691
+ s.length > 256 ? s[0, 256] : s
692
+ end
693
+
652
694
  # Prepend a failed batch to the front of the buffer (oldest-first, so it
653
695
  # retries first) and re-apply the cap. Caller must NOT hold @mutex.
654
696
  def requeue(batch)
@@ -1057,7 +1099,7 @@ module Mcpeye
1057
1099
  def normalize_identity(identity)
1058
1100
  h = identity.is_a?(Hash) ? identity : {}
1059
1101
  out = {}
1060
- %w[userId client serverVersion].each do |k|
1102
+ %w[userId userEmail client serverVersion].each do |k|
1061
1103
  v = h[k.to_sym]
1062
1104
  v = h[k] if v.nil?
1063
1105
  next if v.nil?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mcpeye
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.4"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcpeye
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - mcpeye