chore: auto-commit (12 archivos)

- app.md
- backend/handlers.go
- backend/main.go
- frontend/src/App.tsx
- frontend/src/api.ts
- frontend/vite.config.ts
- backend/mcp_http.go
- backend/mcp_tokens.go
- backend/mcp_tokens_handlers.go
- backend/migrations/016_mcp_tokens.sql
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 14:38:17 +02:00
parent c9e15513c7
commit c28ae7d3c0
13 changed files with 771 additions and 4 deletions
+3
View File
@@ -670,6 +670,9 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
{Method: "GET", Path: "/api/notifications/unread-count", Handler: handleUnreadCount(db)},
{Method: "POST", Path: "/api/notifications/{id}/read", Handler: handleMarkNotificationRead(db, hub)},
{Method: "POST", Path: "/api/notifications/read-all", Handler: handleMarkAllNotificationsRead(db, hub)},
{Method: "POST", Path: "/api/mcp-tokens", Handler: handleCreateMCPToken(db)},
{Method: "GET", Path: "/api/mcp-tokens", Handler: handleListMCPTokens(db)},
{Method: "DELETE", Path: "/api/mcp-tokens/{id}", Handler: handleRevokeMCPToken(db)},
{Method: "GET", Path: "/api/modules", Handler: handleListModules(db)},
{Method: "POST", Path: "/api/modules", Handler: handleCreateModule(db)},
{Method: "PATCH", Path: "/api/modules/{id}", Handler: handleUpdateModule(db)},
+2
View File
@@ -74,6 +74,8 @@ func main() {
defer dispatcher.Stop()
mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken, &featureFlags, hub, dispatcher))
mux.Handle("/mcp", mcpHTTPHandler(db))
feHandler := frontendHandler()
if feHandler != nil {
mux.Handle("/", feHandler)
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"fn-registry/functions/infra"
)
// mcpHTTPHandler builds the http.Handler that serves the MCP Streamable HTTP
// transport for remote Claude clients. Bearer-auth backed by the mcp_tokens
// table; tool dispatch reuses executeTool() — the same set of operations the
// chat assistant uses internally.
func mcpHTTPHandler(db *DB) http.Handler {
auth := func(r *http.Request) (context.Context, error) {
header := r.Header.Get("Authorization")
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if token == "" || token == header {
return nil, errors.New("missing bearer token")
}
userID, err := db.LookupMCPToken(token)
if err != nil {
return nil, err
}
if userID == "" {
return nil, errors.New("invalid or revoked token")
}
return context.WithValue(r.Context(), userCtxKey, userID), nil
}
handler := func(ctx context.Context, name string, input json.RawMessage) (any, bool, error) {
body := input
if len(body) == 0 {
body = json.RawMessage(`{}`)
}
res := executeTool(db, name, body)
if !res.OK {
return res.Error, true, nil
}
return res.Result, false, nil
}
return infra.MCPHTTPHandler(infra.MCPHTTPOpts{
Name: "kanban",
Version: Version,
Tools: mcpToolDefs(),
Handler: handler,
Auth: auth,
Logger: os.Stderr,
})
}
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
)
// MCPToken is a per-user access token used by remote Claude clients to talk to
// the kanban MCP HTTP endpoint. The plaintext value is shown ONCE at creation
// time; we only persist the SHA-256 hash.
type MCPToken struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
LastUsedAt *string `json:"last_used_at,omitempty"`
}
const mcpTokenPrefix = "kmcp_"
var errMCPTokenNotFound = errors.New("mcp token not found")
// MintMCPToken creates a new active token for userID and returns the plaintext
// value (caller must surface it to the user immediately; it cannot be
// recovered later) along with the row metadata.
func (db *DB) MintMCPToken(userID, name string) (string, *MCPToken, error) {
if userID == "" {
return "", nil, fmt.Errorf("user_id required")
}
plaintext, err := generateMCPTokenPlaintext()
if err != nil {
return "", nil, fmt.Errorf("generate token: %w", err)
}
tok := &MCPToken{
ID: newID(),
Name: name,
CreatedAt: nowRFC3339(),
}
_, err = db.conn.Exec(
`INSERT INTO mcp_tokens (id, user_id, token_hash, name, created_at) VALUES (?, ?, ?, ?, ?)`,
tok.ID, userID, hashMCPToken(plaintext), tok.Name, tok.CreatedAt,
)
if err != nil {
return "", nil, err
}
return plaintext, tok, nil
}
func (db *DB) ListMCPTokens(userID string) ([]MCPToken, error) {
rows, err := db.conn.Query(
`SELECT id, name, created_at, last_used_at FROM mcp_tokens
WHERE user_id=? AND revoked_at IS NULL
ORDER BY created_at DESC`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []MCPToken{}
for rows.Next() {
var t MCPToken
var lastUsed sql.NullString
if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &lastUsed); err != nil {
return nil, err
}
if lastUsed.Valid {
t.LastUsedAt = &lastUsed.String
}
out = append(out, t)
}
return out, rows.Err()
}
// RevokeMCPToken sets revoked_at on the token belonging to userID. Returns
// errMCPTokenNotFound if no active row matches.
func (db *DB) RevokeMCPToken(userID, tokenID string) error {
res, err := db.conn.Exec(
`UPDATE mcp_tokens SET revoked_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL`,
nowRFC3339(), tokenID, userID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return errMCPTokenNotFound
}
return nil
}
// LookupMCPToken hashes plaintext and returns the owning user_id if the token
// is active. Updates last_used_at as a side effect. Returns "" + nil when the
// token does not match an active row.
func (db *DB) LookupMCPToken(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
hash := hashMCPToken(plaintext)
var userID, id string
err := db.conn.QueryRow(
`SELECT id, user_id FROM mcp_tokens WHERE token_hash=? AND revoked_at IS NULL`, hash,
).Scan(&id, &userID)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
if _, err := db.conn.Exec(`UPDATE mcp_tokens SET last_used_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
return userID, fmt.Errorf("touch last_used_at: %w", err)
}
return userID, nil
}
func hashMCPToken(plaintext string) string {
sum := sha256.Sum256([]byte(plaintext))
return hex.EncodeToString(sum[:])
}
func generateMCPTokenPlaintext() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return mcpTokenPrefix + hex.EncodeToString(b), nil
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"errors"
"net/http"
"strings"
"fn-registry/functions/infra"
)
// POST /api/mcp-tokens {name}
//
// Mints a new MCP token for the current user. The plaintext token is returned
// ONLY in this response — there is no way to retrieve it again.
func handleCreateMCPToken(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if userID == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "login required"})
return
}
var body struct {
Name string `json:"name"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
name = "default"
}
plaintext, tok, err := db.MintMCPToken(userID, name)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, map[string]any{
"id": tok.ID,
"name": tok.Name,
"created_at": tok.CreatedAt,
"token": plaintext,
})
}
}
// GET /api/mcp-tokens
func handleListMCPTokens(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if userID == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "login required"})
return
}
tokens, err := db.ListMCPTokens(userID)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, tokens)
}
}
// DELETE /api/mcp-tokens/{id}
func handleRevokeMCPToken(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if userID == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "login required"})
return
}
id := r.PathValue("id")
if err := db.RevokeMCPToken(userID, id); err != nil {
if errors.Is(err, errMCPTokenNotFound) {
notFound(w, "token not found")
return
}
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+26
View File
@@ -0,0 +1,26 @@
-- Per-user MCP access tokens. Users mint tokens from the settings UI and
-- paste them into their local Claude (`claude mcp add --transport http ...`).
-- The plaintext token is shown ONCE at creation time; we only store the hash.
--
-- token_hash is a SHA-256 hex digest of the plaintext token. Lookup on
-- incoming requests: hash the bearer, look up the row, accept if not revoked.
--
-- revoked_at is NULL for active tokens. Tokens are never deleted (audit
-- trail); revocation is a soft delete.
CREATE TABLE IF NOT EXISTS mcp_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_mcp_tokens_user_active
ON mcp_tokens(user_id)
WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_mcp_tokens_hash_active
ON mcp_tokens(token_hash)
WHERE revoked_at IS NULL;