Ctrl K
ComunidadGuías…

internal/mcpsession/service.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package mcpsession

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/stump-wtf/cairn/internal/errs"
	"github.com/stump-wtf/cairn/internal/user"
)

// Service is the MCP session core over Postgres, sharing the same pool the
// artifact store and other in-process cores use (ADR-0012: one binary, one
// database). Every query is parameterized (SPEC-0007 REQ "Database Operation
// Standards").
type Service struct {
	pool *pgxpool.Pool
	now  func() time.Time
}

// NewService builds the MCP session core over pool.
func NewService(pool *pgxpool.Pool) *Service {
	return &Service{pool: pool, now: time.Now}
}

// Record opens (or reopens, on a rare transport session-id collision — see
// below) a session row at the MCP `initialize` handshake. The insert is an
// upsert on the primary key: a streamable-HTTP transport is documented to
// mint a globally-unique session id per connection, but re-initializing an
// already-open id (a client retry before the first initialize's response
// landed) is idempotent rather than a constraint-violation 500 — it resets
// the connected/activity clocks and client identity to this call's view
// rather than erroring or silently keeping stale data.
func (s *Service) Record(ctx context.Context, in RecordInput) (*Session, error) {
	if in.ID == "" || !user.ValidID(in.UserID) || in.GrantID == "" {
		return nil, errs.Validationf("mcp session: id, owner, and grant are required")
	}
	now := s.now().UTC()
	if _, err := s.pool.Exec(ctx, `
		INSERT INTO mcp_sessions (id, user_id, grant_id, client_id, client_name, client_version, connected_at, last_activity_at)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
		ON CONFLICT (id) DO UPDATE SET
			user_id = EXCLUDED.user_id,
			grant_id = EXCLUDED.grant_id,
			client_id = EXCLUDED.client_id,
			client_name = EXCLUDED.client_name,
			client_version = EXCLUDED.client_version,
			connected_at = EXCLUDED.connected_at,
			last_activity_at = EXCLUDED.last_activity_at`,
		in.ID, in.UserID, in.GrantID, in.ClientID, in.ClientName, in.ClientVersion, now,
	); err != nil {
		return nil, fmt.Errorf("mcpsession: record session: %w", err)
	}
	return &Session{
		ID: in.ID, UserID: in.UserID, GrantID: in.GrantID, ClientID: in.ClientID,
		ClientName: in.ClientName, ClientVersion: in.ClientVersion,
		ConnectedAt: now, LastActivityAt: now,
	}, nil
}

// Touch records one unit of activity on sessionID: it always bumps
// last_activity_at and tool_calls (every call, success or failure, is
// activity worth surfacing), and additionally increments artifacts_created
// or annotations_posted when kind names a successful create/annotate call.
// An unknown sessionID (a session this process never recorded, e.g. after a
// restart before initialize) is a silent no-op — activity counters are a
// best-effort presentation aid, not a correctness-critical ledger, so a miss
// here must never fail the tool call it is piggybacked on.
func (s *Service) Touch(ctx context.Context, sessionID string, kind Activity) error {
	if sessionID == "" {
		return nil
	}
	now := s.now().UTC()
	var extra string
	switch kind {
	case ActivityArtifactCreated:
		extra = ", artifacts_created = artifacts_created + 1"
	case ActivityAnnotationPosted:
		extra = ", annotations_posted = annotations_posted + 1"
	}
	if _, err := s.pool.Exec(ctx, `
		UPDATE mcp_sessions
		SET last_activity_at = $1, tool_calls = tool_calls + 1`+extra+`
		WHERE id = $2`,
		now, sessionID,
	); err != nil {
		return fmt.Errorf("mcpsession: touch session %s: %w", sessionID, err)
	}
	return nil
}

// sessionColumns is the column list shared by List and Get, joining
// oauth_grants for its revoked_at so a caller can tell a live connection from
// an ended one without a second query.
const sessionColumns = `
	s.id, s.user_id::text, s.grant_id, s.client_id, s.client_name, s.client_version,
	s.connected_at, s.last_activity_at, s.tool_calls, s.artifacts_created, s.annotations_posted,
	g.revoked_at`

// List returns userID's MCP sessions (most recently active first),
// owner-scoped (SPEC-0007 acceptance: "owner isolation"). It reports both
// live and ended (grant-revoked) sessions — recent history, not just active
// connections — so limit bounds how far back "recent" reaches.
func (s *Service) List(ctx context.Context, userID string, limit int) ([]*Session, error) {
	if userID == "" {
		return nil, errs.Validationf("mcp session: owner is required")
	}
	if limit <= 0 {
		limit = 50
	}
	rows, err := s.pool.Query(ctx, `
		SELECT `+sessionColumns+`
		FROM mcp_sessions s
		JOIN oauth_grants g ON g.grant_id = s.grant_id
		WHERE s.user_id = $1
		ORDER BY s.last_activity_at DESC
		LIMIT $2`,
		user.IDParam(userID), limit,
	)
	if err != nil {
		return nil, fmt.Errorf("mcpsession: list sessions: %w", err)
	}
	defer rows.Close()

	var out []*Session
	for rows.Next() {
		sess, err := scanSession(rows)
		if err != nil {
			return nil, fmt.Errorf("mcpsession: scan session: %w", err)
		}
		out = append(out, sess)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("mcpsession: list sessions: %w", err)
	}
	return out, nil
}

// Get returns userID's session id, owner-scoped and uniform: a session that
// does not exist or belongs to a different owner is errs.ErrNotFound, so the
// endpoint discloses nothing about another owner's sessions (mirrors
// pat.Service.Revoke's owner-scoped-uniform-404 shape).
func (s *Service) Get(ctx context.Context, userID, id string) (*Session, error) {
	if userID == "" || id == "" {
		return nil, errs.Validationf("mcp session: owner and id are required")
	}
	row := s.pool.QueryRow(ctx, `
		SELECT `+sessionColumns+`
		FROM mcp_sessions s
		JOIN oauth_grants g ON g.grant_id = s.grant_id
		WHERE s.id = $1 AND s.user_id = $2`,
		id, user.IDParam(userID),
	)
	sess, err := scanSession(row)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, fmt.Errorf("mcp session %s: %w", id, errs.ErrNotFound)
	}
	if err != nil {
		return nil, fmt.Errorf("mcpsession: get session %s: %w", id, err)
	}
	return sess, nil
}

// rowScanner is the subset of pgx.Rows / pgx.Row Scan needs.
type rowScanner interface {
	Scan(dest ...any) error
}

func scanSession(row rowScanner) (*Session, error) {
	var s Session
	if err := row.Scan(
		&s.ID, &s.UserID, &s.GrantID, &s.ClientID, &s.ClientName, &s.ClientVersion,
		&s.ConnectedAt, &s.LastActivityAt, &s.ToolCalls, &s.ArtifactsCreated, &s.AnnotationsPosted,
		&s.GrantRevokedAt,
	); err != nil {
		return nil, err
	}
	return &s, nil
}