2026-03-29-phase1-signup-mcp-tokens
Phase 1: Sign-up + MCP Tokens Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Enable user registration and authenticated MCP access via personal tokens.
Architecture: Reuse existing sign-in-by-code for sign-up (backend already creates users on first sign-in). Add mcp_tokens table (SHA256-hashed, like api_keys). MCP endpoint resolves ?token= param to user. New user/space tab for token CRUD. Cronjob cleans revoked tokens after 7 days.
Tech Stack: Go, SQLite, sqlc, gqlgen, $mol (view.tree + TypeScript), quicktemplate
Task 1: Database migration — create mcp_tokens table
Files:
-
Create:
db/migrations/20260329100000_create_mcp_tokens_table.sql -
Step 1: Create migration file
-- migrate:up
create table mcp_tokens (
id text primary key,
user_id integer not null references users(id) on delete cascade,
name text not null default '',
token_hash text not null unique,
token_prefix text not null,
created_at datetime not null default current_timestamp,
last_used_at datetime,
revoked_at datetime
);
create index idx_mcp_tokens_user_id on mcp_tokens(user_id);
create index idx_mcp_tokens_token_hash on mcp_tokens(token_hash);
-- migrate:down
drop table mcp_tokens;
- Step 2: Run migration
Run: make dbmate-up (or dbmate up depending on project setup)
Expected: Migration applied, mcp_tokens table created.
- Step 3: Regenerate schema.sql
Run: dbmate dump
Expected: db/schema.sql updated with new table.
- Step 4: Commit
git add db/migrations/20260329100000_create_mcp_tokens_table.sql db/schema.sql
git commit -m "feat(mcp): create mcp_tokens table"
Task 2: SQL queries for mcp_tokens
Files:
-
Modify:
queries.write.sql -
Modify:
queries.read.sql -
Step 1: Add write queries to
queries.write.sql
Append to the file:
-- name: InsertMCPToken :one
insert into mcp_tokens (id, user_id, name, token_hash, token_prefix)
values (?, ?, ?, ?, ?)
returning *;
-- name: RevokeMCPToken :one
update mcp_tokens
set revoked_at = datetime('now')
where id = ? and user_id = ? and revoked_at is null
returning *;
-- name: UpdateMCPTokenLastUsedAt :exec
update mcp_tokens
set last_used_at = datetime('now')
where id = ?;
-- name: CleanupRevokedMCPTokens :exec
delete from mcp_tokens
where revoked_at is not null
and revoked_at < datetime('now', '-7 days');
- Step 2: Add read queries to
queries.read.sql
Append to the file:
-- name: MCPTokenByHash :one
select * from mcp_tokens
where token_hash = ? and revoked_at is null
limit 1;
-- name: ListMCPTokensByUserID :many
select * from mcp_tokens
where user_id = ?
order by created_at desc;
-- name: CountMCPTokensByUserID :one
select count(*) from mcp_tokens
where user_id = ?;
- Step 3: Generate Go code
Run: make sqlc
Expected: internal/db/queries.read.sql.go and internal/db/queries.write.sql.go updated with new functions.
- Step 4: Verify generated code compiles
Run: go build ./internal/db/...
Expected: No errors.
- Step 5: Commit
git add queries.write.sql queries.read.sql internal/db/queries.read.sql.go internal/db/queries.write.sql.go
git commit -m "feat(mcp): add sqlc queries for mcp_tokens"
Task 3: MCP token utility — generate, hash, prefix
Files:
-
Create:
internal/mcptoken/token.go -
Create:
internal/mcptoken/token_test.go -
Step 1: Write tests
package mcptoken
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGenerate(t *testing.T) {
token := Generate()
require.True(t, len(token) == 68, "token should be 68 chars: 'mcp_' prefix + 64 random")
require.Equal(t, "mcp_", token[:4], "token should start with 'mcp_' prefix")
// Tokens should be unique
token2 := Generate()
require.NotEqual(t, token, token2)
}
func TestHash(t *testing.T) {
token := "mcp_abcdef1234567890abcdef1234567890abcdef1234567890abcdef12345678"
hash := Hash(token)
require.Len(t, hash, 64, "SHA256 hex should be 64 chars")
// Same input = same hash
require.Equal(t, hash, Hash(token))
// Different input = different hash
require.NotEqual(t, hash, Hash("mcp_different"))
}
func TestPrefix(t *testing.T) {
token := "mcp_abcdef1234567890"
prefix := Prefix(token)
require.Equal(t, "mcp_abcd", prefix, "prefix should be first 8 chars")
}
- Step 2: Run tests to verify they fail
Run: go test ./internal/mcptoken/...
Expected: FAIL — package doesn't exist yet.
- Step 3: Write implementation
package mcptoken
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"math/big"
)
const (
prefix = "mcp_"
alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
length = 64
)
// Generate creates a new MCP token with "mcp_" prefix + 64 random alphanumeric chars.
func Generate() string {
result := make([]byte, length)
for i := range length {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
if err != nil {
panic(err)
}
result[i] = alphabet[n.Int64()]
}
return prefix + string(result)
}
// Hash returns the SHA256 hex digest of the token.
func Hash(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
// Prefix returns the first 8 characters of the token for display.
func Prefix(token string) string {
if len(token) < 8 {
return token
}
return token[:8]
}
- Step 4: Run tests to verify they pass
Run: go test ./internal/mcptoken/...
Expected: PASS (3 tests).
- Step 5: Commit
git add internal/mcptoken/
git commit -m "feat(mcp): add mcptoken utility (generate, hash, prefix)"
Task 4: Create MCP token use case
Files:
-
Create:
internal/case/createmcptoken/resolve.go -
Create:
internal/case/createmcptoken/resolve_test.go -
Step 1: Write tests
package createmcptoken_test
//go:generate go run github.com/matryer/moq -out mocks_test.go -pkg createmcptoken_test . Env
import (
"context"
"testing"
"trip2g/internal/case/createmcptoken"
"trip2g/internal/db"
"trip2g/internal/usertoken"
"github.com/stretchr/testify/require"
)
func TestResolve_Success(t *testing.T) {
env := &EnvMock{
CurrentUserTokenFunc: func(ctx context.Context) (*usertoken.Data, error) {
return &usertoken.Data{ID: 42}, nil
},
CountMCPTokensByUserIDFunc: func(ctx context.Context, userID int64) (int64, error) {
return 3, nil
},
GenerateUniqIDFunc: func() string {
return "test-id"
},
InsertMCPTokenFunc: func(ctx context.Context, params db.InsertMCPTokenParams) (db.McpToken, error) {
return db.McpToken{
ID: params.ID,
UserID: params.UserID,
Name: params.Name,
TokenHash: params.TokenHash,
TokenPrefix: params.TokenPrefix,
}, nil
},
}
result, err := createmcptoken.Resolve(context.Background(), env, createmcptoken.Input{Name: "My Claude"})
require.NoError(t, err)
payload, ok := result.(*createmcptoken.SuccessPayload)
require.True(t, ok)
require.NotEmpty(t, payload.PlaintextToken)
require.Equal(t, "mcp_", payload.PlaintextToken[:4])
require.Equal(t, "test-id", payload.Token.ID)
}
func TestResolve_LimitExceeded(t *testing.T) {
env := &EnvMock{
CurrentUserTokenFunc: func(ctx context.Context) (*usertoken.Data, error) {
return &usertoken.Data{ID: 42}, nil
},
CountMCPTokensByUserIDFunc: func(ctx context.Context, userID int64) (int64, error) {
return 10, nil
},
}
result, err := createmcptoken.Resolve(context.Background(), env, createmcptoken.Input{Name: "Too many"})
require.NoError(t, err)
_, ok := result.(*createmcptoken.ErrorPayload)
require.True(t, ok, "should return error payload when limit exceeded")
}
- Step 2: Generate mocks
Run: go generate ./internal/case/createmcptoken/...
- Step 3: Write implementation
package createmcptoken
import (
"context"
"fmt"
"trip2g/internal/db"
"trip2g/internal/mcptoken"
"trip2g/internal/usertoken"
)
//go:generate go run github.com/matryer/moq -out mocks_test.go -pkg createmcptoken_test . Env
const MaxTokensPerUser = 10
type Env interface {
CurrentUserToken(ctx context.Context) (*usertoken.Data, error)
CountMCPTokensByUserID(ctx context.Context, userID int64) (int64, error)
GenerateUniqID() string
InsertMCPToken(ctx context.Context, params db.InsertMCPTokenParams) (db.McpToken, error)
}
type Input struct {
Name string
}
type SuccessPayload struct {
PlaintextToken string
Token db.McpToken
}
type ErrorPayload struct {
Message string
}
type Payload interface{}
func Resolve(ctx context.Context, env Env, input Input) (Payload, error) {
token, err := env.CurrentUserToken(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get current user token: %w", err)
}
userID := int64(token.ID)
count, err := env.CountMCPTokensByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to count mcp tokens: %w", err)
}
if count >= MaxTokensPerUser {
return &ErrorPayload{Message: "Maximum number of MCP tokens reached (10). Revoke an existing token first."}, nil
}
plaintext := mcptoken.Generate()
hash := mcptoken.Hash(plaintext)
prefix := mcptoken.Prefix(plaintext)
params := db.InsertMCPTokenParams{
ID: env.GenerateUniqID(),
UserID: userID,
Name: input.Name,
TokenHash: hash,
TokenPrefix: prefix,
}
created, err := env.InsertMCPToken(ctx, params)
if err != nil {
return nil, fmt.Errorf("failed to insert mcp token: %w", err)
}
return &SuccessPayload{
PlaintextToken: plaintext,
Token: created,
}, nil
}
- Step 4: Run tests
Run: go test ./internal/case/createmcptoken/...
Expected: PASS.
- Step 5: Commit
git add internal/case/createmcptoken/
git commit -m "feat(mcp): add create mcp token use case"
Task 5: Revoke MCP token use case
Files:
-
Create:
internal/case/revokemcptoken/resolve.go -
Step 1: Write implementation
package revokemcptoken
import (
"context"
"fmt"
"trip2g/internal/db"
"trip2g/internal/model"
"trip2g/internal/usertoken"
)
type Env interface {
CurrentUserToken(ctx context.Context) (*usertoken.Data, error)
RevokeMCPToken(ctx context.Context, arg db.RevokeMCPTokenParams) (db.McpToken, error)
}
type Input struct {
ID string
}
func Resolve(ctx context.Context, env Env, input Input) (db.McpToken, error) {
token, err := env.CurrentUserToken(ctx)
if err != nil {
return db.McpToken{}, fmt.Errorf("failed to get current user token: %w", err)
}
revoked, err := env.RevokeMCPToken(ctx, db.RevokeMCPTokenParams{
ID: input.ID,
UserID: int64(token.ID),
})
if err != nil {
if db.IsNoFound(err) {
return db.McpToken{}, model.NewFieldError("id", "Token not found or already revoked")
}
return db.McpToken{}, fmt.Errorf("failed to revoke mcp token: %w", err)
}
return revoked, nil
}
- Step 2: Verify it compiles
Run: go build ./internal/case/revokemcptoken/...
Expected: No errors. (May need to check the exact db.IsNoFound helper — adjust if the project uses a different pattern.)
- Step 3: Commit
git add internal/case/revokemcptoken/
git commit -m "feat(mcp): add revoke mcp token use case"
Task 6: MCP endpoint auth — resolve token from URL param
Files:
-
Modify:
internal/case/mcp/endpoint.go -
Modify:
internal/case/mcp/resolve.go -
Step 1: Add MCPUser type and extend Env interface in resolve.go
Add to the top of internal/case/mcp/resolve.go, after the existing imports:
// MCPUser represents the authenticated user from an MCP token.
type MCPUser struct {
UserID int64
Role string // "admin" or "user"
}
Extend the Env interface:
type Env interface {
similarnotes.Env
SearchLatestNotes(query string) ([]model.SearchResult, error)
OpenAI() *openai.Client
PublicURL() string
Logger() logger.Logger
MCPTokenByHash(ctx context.Context, hash string) (db.McpToken, error)
UpdateMCPTokenLastUsedAt(ctx context.Context, id string) error
UserRoleByID(ctx context.Context, userID int64) (string, error)
}
- Step 2: Update Resolve signature to accept MCPUser
Change Resolve to accept an optional user:
func Resolve(ctx context.Context, env Env, req Request, user *MCPUser) Response {
switch req.Method {
case MCPMethodInitialize:
return handleInitialize(env, req.ID)
case "notifications/initialized":
return Response{JSONRPC: "2.0", ID: req.ID, Result: map[string]any{}}
case "tools/list":
return handleToolsList(env, req.ID)
case "tools/call":
return handleToolsCall(ctx, env, req)
default:
return errorResponse(req.ID, ErrCodeMethodNotFound, "Method not found: "+req.Method)
}
}
The user param is stored for future use in Phase 2 (admin tools, rate limiting). For now, it's just resolved and passed through.
- Step 3: Add token resolution in endpoint.go
Replace the Handle method:
func (*Endpoint) Handle(req *appreq.Request) (interface{}, error) {
env := req.Env.(Env)
// Resolve MCP token from ?token= param
var user *MCPUser
tokenParam := string(req.Req.QueryArgs().Peek("token"))
if tokenParam != "" {
resolved, err := resolveMCPToken(req.Req, env, tokenParam)
if err != nil {
resp := errorResponse(nil, ErrCodeInvalidParams, "Invalid token: "+err.Error())
return writeJSONResponse(req, resp)
}
user = resolved
}
// Parse JSON-RPC request
var rpcReq Request
err := json.Unmarshal(req.Req.PostBody(), &rpcReq)
if err != nil {
resp := errorResponse(nil, ErrCodeParseError, "Parse error: "+err.Error())
return writeJSONResponse(req, resp)
}
// Validate JSON-RPC version
if rpcReq.JSONRPC != "2.0" {
resp := errorResponse(rpcReq.ID, ErrCodeInvalidRequest, "Invalid JSON-RPC version")
return writeJSONResponse(req, resp)
}
// Handle request
resp := Resolve(req.Req, env, rpcReq, user) // req.Req is fasthttp.RequestCtx which implements context.Context
return writeJSONResponse(req, resp)
}
- Step 4: Add resolveMCPToken helper in endpoint.go
func resolveMCPToken(ctx context.Context, env Env, plainToken string) (*MCPUser, error) {
hash := mcptoken.Hash(plainToken)
token, err := env.MCPTokenByHash(ctx, hash)
if err != nil {
return nil, fmt.Errorf("token not found")
}
// Update last_used_at in background (best effort)
go func() {
_ = env.UpdateMCPTokenLastUsedAt(context.Background(), token.ID)
}()
role, err := env.UserRoleByID(ctx, token.UserID)
if err != nil {
return nil, fmt.Errorf("failed to resolve user role")
}
return &MCPUser{
UserID: token.UserID,
Role: role,
}, nil
}
Add the import for "trip2g/internal/mcptoken".
- Step 5: Add UserRoleByID query
Add to queries.read.sql:
-- name: UserRoleByID :one
select case when a.user_id is not null then 'admin' else 'user' end as role
from users u
left join admins a on u.id = a.user_id
where u.id = ?;
Run: make sqlc
Note: UserRoleByID may need to return a custom type. If sqlc generates it as a string, it works directly. Check the generated code and adapt if needed.
- Step 6: Update existing tests
Update internal/case/mcp/resolve_test.go — add the new Env methods to the mock and update Resolve calls to pass nil for the user param.
- Step 7: Verify all tests pass
Run: go test ./internal/case/mcp/...
Expected: PASS.
- Step 8: Commit
git add internal/case/mcp/ internal/mcptoken/ queries.read.sql internal/db/queries.read.sql.go
git commit -m "feat(mcp): add token auth resolution to MCP endpoint"
Task 7: Cronjob — cleanup revoked MCP tokens
Files:
-
Create:
internal/case/cronjob/cleanuprevokedmcptokens/job.go -
Create:
internal/case/cronjob/cleanuprevokedmcptokens/resolve.go -
Modify:
cmd/server/cronjobs.go -
Step 1: Write job.go
package cleanuprevokedmcptokens
import (
"context"
)
type Job struct{}
func (j *Job) Name() string {
return "cleanup_revoked_mcp_tokens"
}
// Schedule runs daily at 4:00 AM.
func (j *Job) Schedule() string {
return "0 0 4 * * *"
}
func (j *Job) ExecuteAfterStart() bool {
return false
}
func (j *Job) Execute(ctx context.Context, env any) (any, error) {
return Resolve(ctx, env.(Env))
}
- Step 2: Write resolve.go
package cleanuprevokedmcptokens
import (
"context"
"fmt"
"trip2g/internal/logger"
)
type Env interface {
CleanupRevokedMCPTokens(ctx context.Context) error
Logger() logger.Logger
}
type Result struct {
Cleaned bool
}
func Resolve(ctx context.Context, env Env) (*Result, error) {
err := env.CleanupRevokedMCPTokens(ctx)
if err != nil {
return nil, fmt.Errorf("failed to cleanup revoked mcp tokens: %w", err)
}
return &Result{Cleaned: true}, nil
}
- Step 3: Register cronjob in
cmd/server/cronjobs.go
Add import:
"trip2g/internal/case/cronjob/cleanuprevokedmcptokens"
Add compile-time check:
_ cleanuprevokedmcptokens.Env = app
Add to jobs slice:
&cleanuprevokedmcptokens.Job{},
- Step 4: Implement Env method on app
In cmd/server/main.go, add:
func (a *app) CleanupRevokedMCPTokens(ctx context.Context) error {
return a.writeDB.CleanupRevokedMCPTokens(ctx)
}
- Step 5: Verify compilation
Run: go build ./cmd/server/...
Expected: No errors.
- Step 6: Commit
git add internal/case/cronjob/cleanuprevokedmcptokens/ cmd/server/cronjobs.go cmd/server/main.go
git commit -m "feat(mcp): add cronjob to cleanup revoked mcp tokens after 7 days"
Task 8: GraphQL schema + resolvers
Files:
-
Modify:
internal/graph/schema.graphqls -
Modify:
internal/graph/schema.resolvers.go(after gqlgen) -
Modify:
internal/graph/resolver.go(Env interface) -
Step 1: Add GraphQL types and mutations to schema.graphqls
Add types:
type McpToken {
id: ID!
name: String!
tokenPrefix: String!
createdAt: Time!
lastUsedAt: Time
revokedAt: Time
}
type CreateMcpTokenPayload {
plaintextToken: String!
token: McpToken!
}
type RevokeMcpTokenPayload {
token: McpToken!
}
input CreateMcpTokenInput {
name: String!
}
input RevokeMcpTokenInput {
id: ID!
}
Add to type User fields:
mcpTokens: [McpToken!]!
Add mutations (inside type Mutation or via viewer pattern — follow existing project convention):
createMcpToken(input: CreateMcpTokenInput!): CreateMcpTokenPayload!
revokeMcpToken(input: RevokeMcpTokenInput!): RevokeMcpTokenPayload!
- Step 2: Run gqlgen
Run: make gqlgen
Expected: Generates resolver stubs in schema.resolvers.go.
- Step 3: Implement resolvers
In the generated resolver stubs, wire to use cases:
For CreateMcpToken:
func (r *mutationResolver) CreateMcpToken(ctx context.Context, input model.CreateMcpTokenInput) (*model.CreateMcpTokenPayload, error) {
result, err := createmcptoken.Resolve(ctx, r.env(ctx), createmcptoken.Input{Name: input.Name})
if err != nil {
return nil, err
}
switch p := result.(type) {
case *createmcptoken.SuccessPayload:
return &model.CreateMcpTokenPayload{
PlaintextToken: p.PlaintextToken,
Token: &model.McpToken{
ID: p.Token.ID,
Name: p.Token.Name,
TokenPrefix: p.Token.TokenPrefix,
CreatedAt: p.Token.CreatedAt,
LastUsedAt: p.Token.LastUsedAt,
RevokedAt: p.Token.RevokedAt,
},
}, nil
case *createmcptoken.ErrorPayload:
return nil, fmt.Errorf(p.Message)
default:
return nil, fmt.Errorf("unexpected payload type")
}
}
For RevokeMcpToken:
func (r *mutationResolver) RevokeMcpToken(ctx context.Context, input model.RevokeMcpTokenInput) (*model.RevokeMcpTokenPayload, error) {
revoked, err := revokemcptoken.Resolve(ctx, r.env(ctx), revokemcptoken.Input{ID: input.ID})
if err != nil {
return nil, err
}
return &model.RevokeMcpTokenPayload{
Token: &model.McpToken{
ID: revoked.ID,
Name: revoked.Name,
TokenPrefix: revoked.TokenPrefix,
CreatedAt: revoked.CreatedAt,
LastUsedAt: revoked.LastUsedAt,
RevokedAt: revoked.RevokedAt,
},
}, nil
}
For User.McpTokens field resolver:
func (r *userResolver) McpTokens(ctx context.Context, obj *model.User) ([]*model.McpToken, error) {
token, err := r.env(ctx).CurrentUserToken(ctx)
if err != nil {
return nil, err
}
tokens, err := r.env(ctx).ListMCPTokensByUserID(ctx, int64(token.ID))
if err != nil {
return nil, err
}
result := make([]*model.McpToken, len(tokens))
for i, t := range tokens {
result[i] = &model.McpToken{
ID: t.ID,
Name: t.Name,
TokenPrefix: t.TokenPrefix,
CreatedAt: t.CreatedAt,
LastUsedAt: t.LastUsedAt,
RevokedAt: t.RevokedAt,
}
}
return result, nil
}
Note: Adapt the resolver code to the exact types gqlgen generates — field names may differ slightly. Check the generated model package.
- Step 4: Add Env methods to resolver Env interface and implement on app
In internal/graph/resolver.go, add to Env interface:
createmcptoken.Env
revokemcptoken.Env
ListMCPTokensByUserID(ctx context.Context, userID int64) ([]db.McpToken, error)
In cmd/server/main.go, implement the methods that delegate to the DB:
func (a *app) InsertMCPToken(ctx context.Context, params db.InsertMCPTokenParams) (db.McpToken, error) {
return a.writeDB.InsertMCPToken(ctx, params)
}
func (a *app) CountMCPTokensByUserID(ctx context.Context, userID int64) (int64, error) {
return a.readDB.CountMCPTokensByUserID(ctx, userID)
}
func (a *app) RevokeMCPToken(ctx context.Context, arg db.RevokeMCPTokenParams) (db.McpToken, error) {
return a.writeDB.RevokeMCPToken(ctx, arg)
}
func (a *app) ListMCPTokensByUserID(ctx context.Context, userID int64) ([]db.McpToken, error) {
return a.readDB.ListMCPTokensByUserID(ctx, userID)
}
func (a *app) MCPTokenByHash(ctx context.Context, hash string) (db.McpToken, error) {
return a.readDB.MCPTokenByHash(ctx, hash)
}
func (a *app) UpdateMCPTokenLastUsedAt(ctx context.Context, id string) error {
return a.writeDB.UpdateMCPTokenLastUsedAt(ctx, id)
}
func (a *app) UserRoleByID(ctx context.Context, userID int64) (string, error) {
return a.readDB.UserRoleByID(ctx, userID)
}
- Step 5: Verify compilation
Run: go build ./...
Expected: No errors.
- Step 6: Regenerate frontend GraphQL types
Run: npm run graphqlgen
Expected: Frontend TypeScript types updated with new mutations and McpToken type.
- Step 7: Commit
git add internal/graph/ cmd/server/main.go
git commit -m "feat(mcp): add GraphQL schema and resolvers for mcp tokens"
Task 9: Frontend — MCP tokens tab in user/space widget
Files:
-
Create:
assets/ui/user/space/mcp_tokens/mcp_tokens.view.tree -
Create:
assets/ui/user/space/mcp_tokens/mcp_tokens.view.ts -
Create:
assets/ui/user/space/mcp_tokens/mcp_tokens.view.css.ts -
Modify:
assets/ui/user/space/space.view.tree -
Step 1: Add MCP tab to space widget
In assets/ui/user/space/space.view.tree, add the new tab in spreads:
spreads *
home <= Home $trip2g_user_space_home
subscriptions <= Subscriptions $trip2g_user_space_subscriptions
mcp <= Mcp $trip2g_user_space_mcp_tokens
- Step 2: Create mcp_tokens.view.tree
$trip2g_user_space_mcp_tokens $mol_view
sub /
<= Page $mol_page
title @ \MCP Tokens
tools /
<= Add_button $mol_button_minor
title @ \+ Add
click? <=> add_token? null
body /
<= Created_alert $mol_view
sub / <= created_alert_content /
<= List $mol_list
rows <= rows /
<= Row*0 $mol_row
sub <= row_content* /
<= Name_cell* $mol_labeler
title @ \Name
Content <= Name_text* $mol_view
sub / <= row_name* \
<= Prefix_cell* $mol_labeler
title @ \Token
Content <= Prefix_text* $mol_view
sub / <= row_prefix* \
<= Created_cell* $mol_labeler
title @ \Created
Content <= Created_time* $trip2g_table_cell_time
value <= row_created_at* null
<= Used_cell* $mol_labeler
title @ \Last used
Content <= Used_time* $trip2g_table_cell_time
value <= row_last_used_at* null
<= Url_cell* $mol_labeler
title @ \MCP URL
Content <= Url_copy* $mol_button_copy
text <= row_mcp_url* \
<= Revoke_cell* $mol_view
sub /
<= Revoke_button* $mol_button_minor
title @ \Revoke
click? <=> revoke_token*? null
- Step 3: Create mcp_tokens.view.ts
namespace $.$$ {
const listQuery = $trip2g_graphql_request(/* GraphQL */ `
query UserMcpTokens {
viewer {
user {
mcpTokens {
id
name
tokenPrefix
createdAt
lastUsedAt
revokedAt
}
}
}
}
`)
const createMutation = $trip2g_graphql_request(/* GraphQL */ `
mutation CreateMcpToken($input: CreateMcpTokenInput!) {
createMcpToken(input: $input) {
plaintextToken
token {
id
name
tokenPrefix
createdAt
}
}
}
`)
const revokeMutation = $trip2g_graphql_request(/* GraphQL */ `
mutation RevokeMcpToken($input: RevokeMcpTokenInput!) {
revokeMcpToken(input: $input) {
token {
id
revokedAt
}
}
}
`)
export class $trip2g_user_space_mcp_tokens extends $.$trip2g_user_space_mcp_tokens {
@$mol_mem
created_token( next?: string | null ): string | null {
return next ?? null
}
override created_alert_content() {
const token = this.created_token()
if( !token ) return []
return [ token ]
}
@$mol_mem
data( reset?: null ) {
const res = listQuery()
if( !res.viewer.user ) throw new Error( 'User not found' )
return $trip2g_graphql_make_map(
res.viewer.user.mcpTokens.filter( ( t: any ) => !t.revokedAt )
)
}
override rows() {
return this.data().map( key => this.Row( key ) )
}
row( id: any ) {
return this.data().get( id )
}
override row_name( id: any ): string {
return this.row( id ).name || '(unnamed)'
}
override row_prefix( id: any ): string {
return this.row( id ).tokenPrefix + '...'
}
override row_created_at( id: any ) {
return this.row( id ).createdAt
}
override row_last_used_at( id: any ) {
return this.row( id ).lastUsedAt
}
override row_mcp_url( id: any ): string {
// This is a placeholder — plaintext token is only shown at creation
return location.origin + '/_system/mcp?token=' + this.row( id ).tokenPrefix + '...'
}
override add_token() {
const name = prompt( 'Token name:' )
if( !name ) return
const res = createMutation( { input: { name } } )
const url = location.origin + '/_system/mcp?token=' + res.createMcpToken.plaintextToken
this.created_token( url )
this.data( null )
}
override revoke_token( id: any ) {
if( !confirm( 'Revoke this token?' ) ) return
revokeMutation( { input: { id } } )
this.data( null )
}
}
}
- Step 4: Create mcp_tokens.view.css.ts
namespace $ {
$mol_style_define( $trip2g_user_space_mcp_tokens, {
})
}
- Step 5: Verify build
Run: npm run build
Expected: No errors.
- Step 6: Commit
git add assets/ui/user/space/
git commit -m "feat(mcp): add MCP tokens tab to user/space widget"
Task 10: Wire everything together and integration test
Files:
-
Modify:
cmd/server/main.go(Env method implementations) -
Modify:
internal/graph/resolver.go(Env interface) -
Step 1: Verify all Env methods are implemented on app
Run: go build ./cmd/server/...
If there are missing method errors, implement them following the pattern in Task 8 Step 4 — delegate to a.readDB or a.writeDB.
- Step 2: Run full test suite
Run: go test ./...
Expected: All tests pass.
- Step 3: Manual smoke test
Start dev server:
make air
Test flow:
- Open site, navigate to user/space widget
- See "MCP" tab
- Click "Add", enter name, get token URL
- Copy URL, test with curl:
curl -X POST "http://localhost:8081/_system/mcp?token=<your_token>" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
Expected: JSON-RPC response with protocolVersion and instructions.
- Test without token (guest):
curl -X POST "http://localhost:8081/_system/mcp" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Expected: Tools list (guest access).
- Test with invalid token:
curl -X POST "http://localhost:8081/_system/mcp?token=invalid" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
Expected: Error response "Invalid token".
- Step 4: Commit final wiring
git add cmd/server/ internal/graph/
git commit -m "feat(mcp): wire up mcp tokens end-to-end"