Extract ActivityPub outbox walk into internal/activitypub/outbox.go with webfinger/actor resolution, Create/Note filtering and IRI dereferencing. Fix searchStatus returning wrong post, nil-account panic in SearchActors, @local handle resolution, empty Author via noteToInternalPost, protocol- relative URL handling, pagination loops, limit clamping (200) and cacheErr masking. Add per-host token-bucket throttle (1/s, cross-host concurrency) in throttle.go and background GTS warming via search?resolve for outbox- fetched posts without following remotes. Align AGENTS.md. Co-Authored-By: internal-model
6.2 KiB
AGENTS.md — naco-api
Quick commands
# Verify (no CI — match these locally)
gofmt -l . # fail if any output
go build -v ./...
go test -v -cover ./...
# Single package / single test
go test -v -cover ./internal/server/... -run TestGetPost
go test -v -cover ./internal/bluesky/...
# Integration tests (requires Postgres + pgvector; never use prod DB)
docker compose -f docker-compose.test.yml up -d # postgres:5432 + ollama:11434
TEST_DATABASE_URL="postgres://postgres:postgres@localhost:5432/mcpserver?sslmode=disable" \
go test -v -cover ./internal/store/...
What this is
Go MCP server (13 tools) for Bluesky / Nostr / ActivityPub / Twitter. Every tool is gated by x402 (HTTP 402 crypto payments, EVM base); settlement → Postgres, optional Matrix webhook.
Tools: getPost, getLatestPosts, getPostsInTime, getFeed (free), getActor, searchActors, getStyleProfile, findSimilarUsers, getEngagementMetrics, getPostingPatterns, getTopicModeling, getSentimentTrends, getEngagementDrivers.
Architecture
cmd/server/main.go entrypoint — wires config, DB, all clients, readyz sidecar
internal/server/ MCP handlers + x402 + analytics (~1800 lines in server.go — start there)
server.go tool dispatch, settlement, prewarm, cache
analytics.go analytics compute + coverage/cache logic
backends.go platform/backend interfaces (fakes for tests)
storefake_test.go in-memory Store fake (DB-free analytics tests)
types.go request/response types, platform enum
auth.go / webhook.go / cache*.go / *Middleware.go / networkInfo.go
*_test.go split suites: mcp_test.go, analytics_test.go, validation_test.go, etc.
internal/activitypub/ AP client; throttle.go per-host token bucket (1/s) for background GTS warming
internal/{bluesky,nostr,twitter,ollama,config,utils,actor,x402}
sql/schema/ 001..007 goose migrations
sql/queries/ sqlc query files → internal/store/
config/models.env single source of truth for EMBEDDING_MODEL / STYLE_MODEL
testClient/rest/ manual live REST endpoint client (not part of go test)
testClient/mcp/ manual live MCP endpoint client (not part of go test)
Code generation
sqlc generates internal/store/*.go from sql/queries/*.sql + sql/schema/:
sqlc generate # after editing any file in sql/
Never hand-edit // Code generated by sqlc. DO NOT EDIT. files.
Migrations
Goose-style (-- +goose up / -- +goose down) in sql/schema/ — 7 files 001_transactions.sql … 007_add_analytics_coverage.sql. Applied at boot when MIGRATE_ON_START=true (default true). New files must be numbered sequentially (008_*.sql).
Testing patterns
- Interface fakes in
internal/server/backends.go+storefake_test.go— no live clients/DB needed. - Call tools via
callTool(s, name, args)through the MCP handler (seemcp_test.go:302). - Integration
internal/store/integration_test.goskipped unlessTEST_DATABASE_URLset — usedocker-compose.test.yml(pgvector:pg16). Point it at a throwaway DB. internal/servertests split by concern (analytics_test.go,searchactors_test.go,styleprofile_test.go,validation_test.go,coverage_test.go,twitter_analytics_test.go);mcp_test.goholds cross-cutting dispatch/cache.
Config
Loaded in order: config/models.env → .env (gitignored, via godotenv) → env. getEnv(k, fallback) fatals if fallback "" and var unset.
| Variable | Default | Notes |
|---|---|---|
GOOSE_DBSTRING |
postgres:///mcpserver |
Server DB (not tests) |
TEST_DATABASE_URL |
— | Tests only, separate DB |
SERVER_ADDRESS / SERVER_PORT |
127.0.0.1 / 8080 |
Unix socket via x402 X402Server; TCP sidecar on READYZ_PORT |
READYZ_PORT |
9090 |
Sidecar serves /readyz (DB+Ollama health) + /metrics |
MIGRATE_ON_START |
true |
Apply sql/schema on boot |
OLLAMA_HOST |
http://localhost:11434 |
Models from config/models.env |
MATRIX_WEBHOOK_URL |
— | If set, payment notifications |
X402_FACILITATOR_URL |
https://facilitator.x402.rs |
|
ACTIVE_NETWORKS |
base |
Comma eip155 IDs |
ENRICH_CROSS_PLATFORM |
true |
findProjectRoot() walks up to go.mod to locate config/models.env; tests with os.Chdir can break it.
Gotchas
bin/gitignored —go build -o bin/mcpserver ./cmd/server/.- Server DB
GOOSE_DBSTRINGvs test DBTEST_DATABASE_URLintentionally separate. testClient/hits liveapi.naco.li— not run bygo test.server.gois large; new tool: register inregisterTools()and addfunc (s *Server) myTool(*mcp.CallToolRequest).- Unix socket MCP + TCP readyz sidecar;
mcpServer.serviceexpectsbin/mainat repo root. - Platform inferred from author handle in
determinePlatform/fetch*— not caller-supplied. - Bluesky auth refresh via
isAuthError/withRefresh— 401/expired token retried once. - Twitter client clamps
limit(>100→ 100), retries 429/5xx/transport errors withx-rate-limit-*/Retry-Afterbackoff (bounded bymaxRateLimitWait), and has a 60s per-request timeout. - ActivityPub client: GTS Mastodon API only serves locally-federated statuses. Remote actors are resolved via
/accounts/search?resolve=true(not/accounts/lookup, which skips WebFinger). When the local cache can't satisfy fulllimit(clamped to 200),GetLatestNPosts/GetPostsInTimeframefall back to walking the remote outbox directly (webfinger → actor →OrderedCollection/OrderedCollectionPage), filteringCreate/Note-like activities and dereferencing IRI-only objects;GetPostderefs status URLs over AP when GTS/searchcan't resolve them. Theread:searchscope is needed for status-URL resolution. Outbox-fetched posts are warmed into GTS in background viaGET /api/v2/search?resolve=true&type=statuses&limit=1per post, throttled at 1 req/s per remote host via per-host token bucket (internal/activitypub/throttle.go) with cross-host concurrency, no follows. - Coverage ranges for analytics are interval-merged; disjoint gaps must remain uncovered (see
coverage_test.go).