diff --git a/internal/mcp/apps.go b/internal/mcp/apps.go
index 7f82f3f..6de27ee 100644
--- a/internal/mcp/apps.go
+++ b/internal/mcp/apps.go
@@ -144,6 +144,7 @@ func (s *Server) registerAppTools() {
// need typed handler generics. Each call registers both tool and resource
// (the resource half is no different from a no-args app).
s.registerInsightsView()
+ s.registerUserView()
}
// registerAppResources registers the ui:// resource half of every MCP App.
diff --git a/internal/mcp/apps_html/dashboard.html b/internal/mcp/apps_html/dashboard.html
index 46936fa..2aa8848 100644
--- a/internal/mcp/apps_html/dashboard.html
+++ b/internal/mcp/apps_html/dashboard.html
@@ -225,19 +225,23 @@
JumpCloud Dashboard
"use strict";
// --- Safe DOM helpers ---
+ // Text-content-only: callers set className/textContent or inline style
+ // properties (string CSS values from a fixed palette). Anything else
+ // is dropped rather than forwarded to setAttribute, so the helper can
+ // never become an XSS surface for href/onclick/srcdoc/etc.
function el(tag, attrs, children) {
var node = document.createElement(tag);
if (attrs) {
Object.keys(attrs).forEach(function(k) {
- if (k === 'className') node.className = attrs[k];
- else if (k === 'textContent') node.textContent = attrs[k];
- else node.style[k] = attrs[k];
+ if (k === 'className') node.className = String(attrs[k]);
+ else if (k === 'textContent') node.textContent = String(attrs[k]);
+ else node.style[k] = String(attrs[k]);
});
}
if (children) {
children.forEach(function(c) {
if (typeof c === 'string') node.appendChild(document.createTextNode(c));
- else if (c) node.appendChild(c);
+ else if (c instanceof Node) node.appendChild(c);
});
}
return node;
diff --git a/internal/mcp/apps_html/insights.html b/internal/mcp/apps_html/insights.html
index eee3a3a..9f0b585 100644
--- a/internal/mcp/apps_html/insights.html
+++ b/internal/mcp/apps_html/insights.html
@@ -249,17 +249,20 @@ Top users
return d.toISOString().slice(5, 16).replace("T", " "); // MM-DD HH:MM
}
+ // Text-content-only DOM builder: callers only set className/textContent
+ // and pass already-constructed Nodes as children. Anything else is a
+ // bug — drop unknown attribute keys rather than forwarding them to
+ // setAttribute (which would open an XSS surface for href/onclick/etc.).
function el(tag, attrs, children) {
var n = document.createElement(tag);
if (attrs) Object.keys(attrs).forEach(function(k){
- if (k === "className") n.className = attrs[k];
- else if (k === "textContent") n.textContent = attrs[k];
- else n.setAttribute(k, attrs[k]);
+ if (k === "className") n.className = String(attrs[k]);
+ else if (k === "textContent") n.textContent = String(attrs[k]);
});
if (children) children.forEach(function(c){
if (c == null) return;
if (typeof c === "string") n.appendChild(document.createTextNode(c));
- else n.appendChild(c);
+ else if (c instanceof Node) n.appendChild(c);
});
return n;
}
diff --git a/internal/mcp/apps_html/user.html b/internal/mcp/apps_html/user.html
new file mode 100644
index 0000000..018b614
--- /dev/null
+++ b/internal/mcp/apps_html/user.html
@@ -0,0 +1,375 @@
+
+
+
+
+
+JumpCloud User Profile
+
+
+
+
+
+
+
+
+
+
+
+
+
Recent events (last 30d)
+
+
+
+
+
+
+
+
+
diff --git a/internal/mcp/apps_user.go b/internal/mcp/apps_user.go
new file mode 100644
index 0000000..ec02647
--- /dev/null
+++ b/internal/mcp/apps_user.go
@@ -0,0 +1,361 @@
+package mcp
+
+import (
+ "context"
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/klaassen-consulting/jc/internal/api"
+ "github.com/klaassen-consulting/jc/internal/resolve"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+//go:embed apps_html/user.html
+var userHTML string
+
+const (
+ userViewResourceURI = "ui://jc/user"
+
+ // Cap on recent events fetched per user to keep the payload bounded;
+ // 50 covers a reasonable activity timeline without dragging the call.
+ userViewRecentEventsLimit = 50
+)
+
+// userViewArgs is the tool input. Single required field — the user
+// identifier accepts username, email, or 24-char hex ID.
+type userViewArgs struct {
+ // User is the JumpCloud user to inspect: username, email, or ID.
+ User string `json:"user" jsonschema:"JumpCloud user to inspect (username, email, or 24-char hex ID)."`
+}
+
+// userViewData is the payload pushed to the app iframe. Mirrors the
+// dashboard's layout: a header section, structured slices for the
+// per-card UI, and a Warnings list when a sub-fetch failed but the
+// overall view is still useful.
+type userViewData struct {
+ User userHeader `json:"user"`
+ MFA userMFA `json:"mfa"`
+ Groups []userGroupRef `json:"groups"`
+ SSHKeys []userSSHKey `json:"ssh_keys"`
+ RecentEvents []json.RawMessage `json:"recent_events"`
+ Warnings []string `json:"warnings,omitempty"`
+}
+
+type userHeader struct {
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ Firstname string `json:"firstname,omitempty"`
+ Lastname string `json:"lastname,omitempty"`
+ Department string `json:"department,omitempty"`
+ Activated bool `json:"activated"`
+ Suspended bool `json:"suspended"`
+ Locked bool `json:"locked"`
+ Created string `json:"created,omitempty"`
+ LastLogin string `json:"last_login,omitempty"`
+ Description string `json:"description,omitempty"`
+}
+
+type userMFA struct {
+ TOTPEnabled bool `json:"totp_enabled"`
+ Status string `json:"status,omitempty"` // ENROLLED / NOT_ENROLLED / etc.
+}
+
+type userGroupRef struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type userSSHKey struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ CreatedDate string `json:"create_date,omitempty"`
+ // PublicKeyPreview is a short, safe-to-display fragment (algorithm prefix
+ // + first dozen chars of the key blob). Full keys aren't sent to the
+ // iframe — they aren't secrets per se but they're long and noisy.
+ PublicKeyPreview string `json:"public_key_preview,omitempty"`
+}
+
+// fetchUserViewData runs the parallel API calls behind user_view and
+// aggregates them into a single payload. Best-effort for sub-fetches:
+// a transient failure on groups or ssh keys lands as a Warning rather
+// than blocking the whole view.
+func fetchUserViewData(ctx context.Context, args userViewArgs) (*userViewData, error) {
+ if args.User == "" {
+ return nil, fmt.Errorf("user is required")
+ }
+
+ v1, err := newV1ClientFunc()
+ if err != nil {
+ return nil, fmt.Errorf("v1 client: %w", err)
+ }
+
+ // Resolve identifier first — every subsequent call needs the ID.
+ id, err := resolveV1(ctx, v1, args.User, resolve.UserConfig)
+ if err != nil {
+ return nil, fmt.Errorf("resolving user %q: %w", args.User, err)
+ }
+
+ // Fetch user detail synchronously before fanning out: the events filter
+ // needs the canonical `username` (not the caller's raw input which may
+ // be an email or hex ID), and the cost is one cheap GET — keeps the
+ // downstream goroutines correct without resorting to channels.
+ rawUser, err := v1.Get(ctx, "/systemusers/"+id)
+ if err != nil {
+ return nil, fmt.Errorf("fetching user %q: %w", args.User, err)
+ }
+ var u struct {
+ ID string `json:"_id"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ Firstname string `json:"firstname"`
+ Lastname string `json:"lastname"`
+ Department string `json:"department"`
+ Description string `json:"description"`
+ Activated bool `json:"activated"`
+ Suspended bool `json:"suspended"`
+ AccountLocked bool `json:"account_locked"`
+ TOTPEnabled bool `json:"totp_enabled"`
+ Created string `json:"created"`
+ MFA struct {
+ Configured bool `json:"configured"`
+ } `json:"mfa"`
+ }
+ if err := json.Unmarshal(rawUser, &u); err != nil {
+ return nil, fmt.Errorf("parsing user %q: %w", args.User, err)
+ }
+ mfaStatus := "NOT_ENROLLED"
+ if u.TOTPEnabled || u.MFA.Configured {
+ mfaStatus = "ENROLLED"
+ }
+
+ var (
+ mu sync.Mutex
+ data userViewData
+ warnings []string
+ )
+ addWarning := func(msg string) {
+ mu.Lock()
+ warnings = append(warnings, msg)
+ mu.Unlock()
+ }
+
+ data.User = userHeader{
+ ID: u.ID, Username: u.Username, Email: u.Email,
+ Firstname: u.Firstname, Lastname: u.Lastname,
+ Department: u.Department, Description: u.Description,
+ Activated: u.Activated, Suspended: u.Suspended, Locked: u.AccountLocked,
+ Created: u.Created,
+ }
+ data.MFA = userMFA{TOTPEnabled: u.TOTPEnabled, Status: mfaStatus}
+
+ var wg sync.WaitGroup
+
+ // Group memberships via the V2 graph: user → user_group.
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ v2, err := newV2ClientFunc()
+ if err != nil {
+ addWarning(fmt.Sprintf("v2 client: %v", err))
+ return
+ }
+ // User → user_group uses the membership endpoint, not graph
+ // associations (the membership endpoint is what the registry
+ // MemberOfTarget points at and the V1 user-groups endpoint
+ // returns).
+ result, err := v2.ListAll(ctx, "/users/"+id+"/memberof", api.V2ListOptions{})
+ if err != nil {
+ addWarning(fmt.Sprintf("groups: %v", err))
+ return
+ }
+ groups := make([]userGroupRef, 0, len(result.Data))
+ for _, raw := range result.Data {
+ var g struct {
+ ID string `json:"id"`
+ Attributes struct {
+ Name string `json:"name"`
+ } `json:"attributes"`
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(raw, &g); err != nil {
+ continue
+ }
+ name := g.Name
+ if name == "" {
+ name = g.Attributes.Name
+ }
+ groups = append(groups, userGroupRef{ID: g.ID, Name: name})
+ }
+ sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
+ mu.Lock()
+ data.Groups = groups
+ mu.Unlock()
+ }()
+
+ // SSH keys.
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ result, err := v1.ListAll(ctx, "/systemusers/"+id+"/sshkeys", api.ListOptions{})
+ if err != nil {
+ addWarning(fmt.Sprintf("ssh keys: %v", err))
+ return
+ }
+ keys := make([]userSSHKey, 0, len(result.Data))
+ for _, raw := range result.Data {
+ var k struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ PublicKey string `json:"public_key"`
+ CreateDate string `json:"create_date"`
+ }
+ if err := json.Unmarshal(raw, &k); err != nil {
+ continue
+ }
+ keys = append(keys, userSSHKey{
+ ID: k.ID, Name: k.Name, CreatedDate: k.CreateDate,
+ PublicKeyPreview: previewSSHKey(k.PublicKey),
+ })
+ }
+ sort.Slice(keys, func(i, j int) bool { return keys[i].Name < keys[j].Name })
+ mu.Lock()
+ data.SSHKeys = keys
+ mu.Unlock()
+ }()
+
+ // Recent insights events: last 30d, filtered to this user.
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ insights, err := newInsightsClientFunc()
+ if err != nil {
+ addWarning(fmt.Sprintf("insights client: %v", err))
+ return
+ }
+ // Filter by the canonical username from the resolved user record —
+ // args.User may be an email or hex ID, neither of which match
+ // `initiated_by.username` in Insights.
+ username := u.Username
+ now := nowFunc().UTC()
+ query := api.InsightsQuery{
+ Service: "all",
+ StartTime: now.Add(-30 * 24 * time.Hour).Format(time.RFC3339),
+ EndTime: now.Format(time.RFC3339),
+ SearchTermFilter: map[string]any{
+ "initiated_by.username": username,
+ },
+ }
+ result, err := insights.QueryEvents(ctx, query, api.InsightsQueryOptions{
+ Limit: userViewRecentEventsLimit,
+ Sort: "-timestamp",
+ })
+ if err != nil {
+ addWarning(fmt.Sprintf("recent events: %v", err))
+ return
+ }
+ mu.Lock()
+ data.RecentEvents = result.Data
+ mu.Unlock()
+ }()
+
+ wg.Wait()
+
+ // Set last_login from the most recent event if we got any.
+ if len(data.RecentEvents) > 0 {
+ var first struct {
+ Timestamp string `json:"timestamp"`
+ }
+ if json.Unmarshal(data.RecentEvents[0], &first) == nil {
+ data.User.LastLogin = first.Timestamp
+ }
+ }
+
+ if len(warnings) > 0 {
+ data.Warnings = warnings
+ }
+
+ // If the header never populated, the user truly couldn't be fetched —
+ // surface that as an error rather than returning an empty card.
+ if data.User.ID == "" && data.User.Username == "" {
+ return nil, fmt.Errorf("could not fetch user %q: %s", args.User, strings.Join(warnings, "; "))
+ }
+
+ return &data, nil
+}
+
+// previewSSHKey returns a short, safe-to-render fragment of the public key
+// (algorithm + first 12 chars of the body) so the UI can show "ssh-ed25519
+// AAAAC3NzaC1lZDI1…" without wall-of-text. Full keys are still publicly
+// shareable but are noisy; truncating here keeps the panel scannable.
+func previewSSHKey(pub string) string {
+ if pub == "" {
+ return ""
+ }
+ const bodyLen = 12
+ // Public keys are " [comment]". Split on the first space.
+ for i := 0; i < len(pub); i++ {
+ if pub[i] == ' ' {
+ algo := pub[:i]
+ rest := pub[i+1:]
+ body := rest
+ if len(body) > bodyLen {
+ body = body[:bodyLen]
+ }
+ return algo + " " + body + "…"
+ }
+ }
+ if len(pub) > bodyLen+8 {
+ return pub[:bodyLen+8] + "…"
+ }
+ return pub
+}
+
+// registerUserView wires the user_view MCP App: typed tool + ui:// resource.
+// Lives outside appSpecs because it takes input args.
+func (s *Server) registerUserView() {
+ meta := mcp.Meta{
+ "ui": map[string]any{"resourceUri": userViewResourceURI},
+ "ui/resourceUri": userViewResourceURI,
+ }
+ addToolWithMetaTyped(s, "user_view",
+ "Show an interactive JumpCloud user profile: header (username, email, status badges), MFA enrollment, group memberships, SSH keys, and recent auth events. "+
+ "Required input: user (username, email, or ID). Renders as a rich profile in MCP App-capable hosts; returns the same data as JSON when rendering isn't supported.",
+ meta,
+ func(ctx context.Context, req *mcp.CallToolRequest, args userViewArgs) (*mcp.CallToolResult, any, error) {
+ data, err := fetchUserViewData(ctx, args)
+ if err != nil {
+ return errorResult(fmt.Sprintf("user_view: %v", err)), nil, nil
+ }
+ res, err := jsonResult(data)
+ if err != nil {
+ return errorResult(err.Error()), nil, nil
+ }
+ return res, nil, nil
+ },
+ )
+
+ rendered := renderAppHTML(userHTML)
+ s.mcpServer.AddResource(
+ &mcp.Resource{
+ URI: userViewResourceURI,
+ Name: "User Profile App",
+ Description: "Interactive JumpCloud user profile (groups, SSH keys, MFA, recent events)",
+ MIMEType: mcpAppMIMEType,
+ },
+ func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
+ return &mcp.ReadResourceResult{
+ Contents: []*mcp.ResourceContents{{
+ URI: userViewResourceURI,
+ MIMEType: mcpAppMIMEType,
+ Text: rendered,
+ }},
+ }, nil
+ },
+ )
+}
diff --git a/internal/mcp/apps_user_test.go b/internal/mcp/apps_user_test.go
new file mode 100644
index 0000000..d39382d
--- /dev/null
+++ b/internal/mcp/apps_user_test.go
@@ -0,0 +1,216 @@
+package mcp
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// startUserViewServer mounts V1 + V2 + Insights endpoints used by
+// fetchUserViewData on a single test server, with deterministic fixtures.
+func startUserViewServer(t *testing.T, user map[string]any, groups []map[string]any, sshKeys []map[string]any, events []map[string]any) *httptest.Server {
+ t.Helper()
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ // V1: user search by username (resolver path)
+ case r.URL.Path == "/api/search/systemusers" && r.Method == "POST":
+ // Resolver POSTs a search; return our single fixture user as the match.
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "results": []map[string]any{user},
+ "totalCount": 1,
+ })
+ case r.URL.Path == "/api/systemusers" && r.Method == "GET":
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "results": []map[string]any{user},
+ "totalCount": 1,
+ })
+ case strings.HasPrefix(r.URL.Path, "/api/systemusers/") && strings.HasSuffix(r.URL.Path, "/sshkeys") && r.Method == "GET":
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "results": sshKeys,
+ "totalCount": len(sshKeys),
+ })
+ case strings.HasPrefix(r.URL.Path, "/api/systemusers/") && r.Method == "GET":
+ // User detail by ID.
+ _ = json.NewEncoder(w).Encode(user)
+
+ // V2: user → group memberships
+ case strings.HasPrefix(r.URL.Path, "/api/v2/users/") && strings.HasSuffix(r.URL.Path, "/memberof") && r.Method == "GET":
+ _ = json.NewEncoder(w).Encode(groups)
+
+ // Insights: events query (POST) — the user_view filters by initiated_by.username.
+ case r.URL.Path == "/insights/directory/v1/events" && r.Method == "POST":
+ _ = json.NewEncoder(w).Encode(events)
+
+ default:
+ w.WriteHeader(404)
+ _ = json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path, "method": r.Method})
+ }
+ }))
+}
+
+func TestPreviewSSHKey(t *testing.T) {
+ cases := []struct {
+ in, want string
+ }{
+ {"", ""},
+ {"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabcdefghijklmnop user@host", "ssh-ed25519 AAAAC3NzaC1l…"},
+ {"ssh-rsa SHORT", "ssh-rsa SHORT…"},
+ {"justabunchoftext", "justabunchoftext"},
+ }
+ for _, c := range cases {
+ got := previewSSHKey(c.in)
+ if got != c.want {
+ t.Errorf("previewSSHKey(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+func TestFetchUserViewData_Aggregates(t *testing.T) {
+ setupToolTest(t)
+
+ // Anchor "now" so the recent-events window is deterministic.
+ origNow := nowFunc
+ nowFunc = func() time.Time { return time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC) }
+ t.Cleanup(func() { nowFunc = origNow })
+
+ user := map[string]any{
+ "_id": "aabbccddee112233aabbcc01",
+ "username": "alice",
+ "email": "alice@acme.com",
+ "firstname": "Alice",
+ "lastname": "Anderson",
+ "department": "Engineering",
+ "activated": true,
+ "suspended": false,
+ "account_locked": false,
+ "totp_enabled": true,
+ "created": "2025-01-15T10:00:00Z",
+ }
+ groups := []map[string]any{
+ {"id": "gg-eng", "attributes": map[string]any{"name": "Engineering"}},
+ {"id": "gg-onc", "attributes": map[string]any{"name": "Oncall"}},
+ }
+ sshKeys := []map[string]any{
+ {"id": "k1", "name": "laptop", "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabcdef alice@laptop", "create_date": "2026-01-10T00:00:00Z"},
+ }
+ events := []map[string]any{
+ {"timestamp": "2026-04-27T11:00:00Z", "service": "sso", "event_type": "sso_auth", "success": true},
+ {"timestamp": "2026-04-26T18:00:00Z", "service": "ldap", "event_type": "ldap_bind", "success": true},
+ }
+
+ ts := startUserViewServer(t, user, groups, sshKeys, events)
+ t.Cleanup(ts.Close)
+ overrideV1ClientForTest(t, ts.URL)
+ overrideV2ClientForTest(t, ts.URL)
+ overrideInsightsClientForTest(t, ts.URL)
+
+ data, err := fetchUserViewData(context.Background(), userViewArgs{User: "alice"})
+ if err != nil {
+ t.Fatalf("fetch: %v", err)
+ }
+
+ // Header
+ if data.User.Username != "alice" {
+ t.Errorf("username = %q, want alice", data.User.Username)
+ }
+ if data.User.Email != "alice@acme.com" {
+ t.Errorf("email = %q", data.User.Email)
+ }
+ if !data.User.Activated || data.User.Locked || data.User.Suspended {
+ t.Errorf("status flags wrong: %+v", data.User)
+ }
+ // MFA
+ if !data.MFA.TOTPEnabled || data.MFA.Status != "ENROLLED" {
+ t.Errorf("mfa = %+v, want enrolled+totp", data.MFA)
+ }
+ // Groups (sorted alphabetically)
+ if len(data.Groups) != 2 {
+ t.Fatalf("groups len = %d, want 2 (%+v)", len(data.Groups), data.Groups)
+ }
+ if data.Groups[0].Name != "Engineering" || data.Groups[1].Name != "Oncall" {
+ t.Errorf("groups = %+v, want sorted Engineering, Oncall", data.Groups)
+ }
+ // SSH keys
+ if len(data.SSHKeys) != 1 || data.SSHKeys[0].Name != "laptop" {
+ t.Errorf("ssh keys = %+v", data.SSHKeys)
+ }
+ if !strings.HasPrefix(data.SSHKeys[0].PublicKeyPreview, "ssh-ed25519 ") {
+ t.Errorf("preview = %q, want algo-prefixed", data.SSHKeys[0].PublicKeyPreview)
+ }
+ // Events
+ if len(data.RecentEvents) != 2 {
+ t.Errorf("events len = %d, want 2", len(data.RecentEvents))
+ }
+ // last_login derived from first event
+ if data.User.LastLogin != "2026-04-27T11:00:00Z" {
+ t.Errorf("last_login = %q, want first event timestamp", data.User.LastLogin)
+ }
+}
+
+func TestFetchUserViewData_RequiresUser(t *testing.T) {
+ _, err := fetchUserViewData(context.Background(), userViewArgs{})
+ if err == nil || !strings.Contains(err.Error(), "user is required") {
+ t.Errorf("expected user-required error, got: %v", err)
+ }
+}
+
+func TestUserView_HasUIMetadata(t *testing.T) {
+ setupToolTest(t)
+ cs := connectToolTestServer(t, Options{})
+
+ result, err := cs.ListTools(context.Background(), nil)
+ if err != nil {
+ t.Fatalf("ListTools: %v", err)
+ }
+ var found *mcp.Tool
+ for _, tool := range result.Tools {
+ if tool.Name == "user_view" {
+ found = tool
+ break
+ }
+ }
+ if found == nil {
+ t.Fatal("user_view tool missing")
+ }
+ meta := map[string]any(found.Meta)
+ ui, ok := meta["ui"].(map[string]any)
+ if !ok {
+ t.Fatalf("expected _meta.ui to be a map, got %T", meta["ui"])
+ }
+ if uri, _ := ui["resourceUri"].(string); uri != userViewResourceURI {
+ t.Errorf("resourceUri = %q, want %q", uri, userViewResourceURI)
+ }
+}
+
+func TestUserViewResource_ServesHTMLWithInjection(t *testing.T) {
+ setupToolTest(t)
+ cs := connectToolTestServer(t, Options{})
+
+ result, err := cs.ReadResource(context.Background(), &mcp.ReadResourceParams{URI: userViewResourceURI})
+ if err != nil {
+ t.Fatalf("ReadResource: %v", err)
+ }
+ if len(result.Contents) == 0 {
+ t.Fatal("empty resource contents")
+ }
+ c := result.Contents[0]
+ if c.MIMEType != mcpAppMIMEType {
+ t.Errorf("MIME = %q, want %q", c.MIMEType, mcpAppMIMEType)
+ }
+ if !strings.Contains(c.Text, "window.jcApp") {
+ t.Error("served HTML missing common.js injection")
+ }
+ if strings.Contains(c.Text, appCommonMarker) {
+ t.Error("served HTML still contains injection marker")
+ }
+ if !strings.Contains(c.Text, "JumpCloud User Profile") {
+ t.Error("served HTML missing page title")
+ }
+}
diff --git a/internal/mcp/tools_test.go b/internal/mcp/tools_test.go
index d6f4ef7..aa8ee50 100644
--- a/internal/mcp/tools_test.go
+++ b/internal/mcp/tools_test.go
@@ -376,6 +376,7 @@ func TestMCP_ListTools_AllRegistered(t *testing.T) {
// MCP Apps
"dashboard_view",
"insights_view",
+ "user_view",
}
toolNames := make(map[string]bool)
@@ -390,8 +391,8 @@ func TestMCP_ListTools_AllRegistered(t *testing.T) {
}
// Verify exact count — update when adding/removing tools.
- if len(result.Tools) != 196 {
- t.Errorf("expected 196 tools, got %d", len(result.Tools))
+ if len(result.Tools) != 197 {
+ t.Errorf("expected 197 tools, got %d", len(result.Tools))
}
}