feat: added AGENTS.md
This commit is contained in:
1 parent
4473a6e3d0
commit
1ecf468140
2 files changed
+198
-30
No files matched your search
@@ -0,0 +1,123 @@
|
|||||||
|
# St-Pierre — Flashcard MCP Server
|
||||||
|
|
||||||
|
MCP server that stores and retrieves flashcards using SQLite for persistence,
|
||||||
|
Qdrant for vector search, and OpenAI-compatible embeddings (LiteLLM proxy).
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- Go (1.26), flat package `main`
|
||||||
|
- MCP server: `github.com/mark3labs/mcp-go`
|
||||||
|
- SQLite: `modernc.org/sqlite` (pure Go, no CGo)
|
||||||
|
- Migrations: `github.com/pressly/goose/v3` (embedded via `//go:embed migrations/*.sql`)
|
||||||
|
- Vector DB: Qdrant via `github.com/qdrant/go-client`
|
||||||
|
- Embeddings: OpenAI-compatible API (LiteLLM at `https://llm.aviron.ai`)
|
||||||
|
- Logging: `go.uber.org/zap`
|
||||||
|
- Deployed on **enlil** (NixOS server) at `https://st-pierre.home.all-dressed-programming.com/mcp`
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
*.go Flat package main (no cmd/ layout)
|
||||||
|
main.go Entry point, MCP server wiring, auth middleware
|
||||||
|
api.go Tool definitions (store_flashcard, search_flashcard) + vote
|
||||||
|
sqlite.go SQLite store — CRUD, search, vote, migration runner
|
||||||
|
fts5.go FTS5 full-text search queries
|
||||||
|
qdrant.go Qdrant gRPC client — upsert, search, collection management
|
||||||
|
embed.go OpenAI-compatible embeddings client
|
||||||
|
search.go Search engine that combines SQLite + Qdrant results
|
||||||
|
types.go Go types (Flashcard, searchRequest, etc.)
|
||||||
|
migrations/ Goose SQL migrations (embedded)
|
||||||
|
public/ Frontend (served via embed.FS or disk in dev mode)
|
||||||
|
public_fs.go Production: embed public/ (no build tag)
|
||||||
|
public_fs_dev.go Dev mode: serve public/ from disk (build tag: dev)
|
||||||
|
flake.nix Nix build + NixOS module
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
nix build
|
||||||
|
|
||||||
|
# Dev (hot-reload frontend)
|
||||||
|
go build -tags dev -o st-pierre .
|
||||||
|
./st-pierre
|
||||||
|
|
||||||
|
# Run directly
|
||||||
|
export OPENAI_API_KEY=sk-... MCP_TOKEN=my-token
|
||||||
|
./st-pierre
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### Goose Migrations
|
||||||
|
|
||||||
|
**NEVER modify an applied migration file.** Goose v3 tracks checksums in
|
||||||
|
`goose_db_version`. Modifying an applied migration causes "checksum mismatch".
|
||||||
|
Always add a new migration with a higher version number. If you must recover
|
||||||
|
a corrupted DB: `DELETE FROM goose_db_version WHERE version_id = N;` or
|
||||||
|
`DROP TABLE goose_db_version;` to reset entirely.
|
||||||
|
|
||||||
|
### SQLite
|
||||||
|
|
||||||
|
- `modernc.org/sqlite` — pure Go, no CGo required
|
||||||
|
- Migrations embedded via `//go:embed migrations/*.sql`
|
||||||
|
- `INTEGER PRIMARY KEY` for auto-increment IDs
|
||||||
|
- `SetMaxOpenConns(1)` mandatory (single-writer SQLite)
|
||||||
|
- Append-only design — never UPDATE rows
|
||||||
|
- Default timestamps: `datetime('now')`
|
||||||
|
- Nullable columns use `sql.NullString`
|
||||||
|
- JSON arrays stored as text columns
|
||||||
|
|
||||||
|
### Nix
|
||||||
|
|
||||||
|
After any `go.mod` or `go.sum` change, update `vendorHash` in `flake.nix`
|
||||||
|
with the hash from a failed build attempt. Never commit go.mod/go.sum changes
|
||||||
|
without updating `vendorHash`.
|
||||||
|
|
||||||
|
`lib.mkIf` is incompatible with the `//` merge operator — it can silently
|
||||||
|
drop attributes. Use `lib.optionalAttrs` instead when merging with `//`.
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
### MCP path
|
||||||
|
|
||||||
|
The MCP server is served at `/mcp` via `http.ServeMux`, not at root. CORS
|
||||||
|
wraps the entire mux. The `result/` symlink created by nix builds is in
|
||||||
|
`.gitignore`.
|
||||||
|
|
||||||
|
### Embedding URL normalization
|
||||||
|
|
||||||
|
`EMBEDDING_BASE_URL` strips any trailing `/v1` and always appends
|
||||||
|
`/v1/embeddings`. A bare host like `https://llm.aviron.ai` correctly becomes
|
||||||
|
`https://llm.aviron.ai/v1/embeddings`. The default is `https://api.openai.com`.
|
||||||
|
|
||||||
|
### Dev build tag
|
||||||
|
|
||||||
|
Two files control how `public/` is served:
|
||||||
|
|
||||||
|
- `public_fs.go` (`//go:build !dev`) — embeds `public/` via `embed.FS`
|
||||||
|
- `public_fs_dev.go` (`//go:build dev`) — serves from disk via `http.Dir("public")`
|
||||||
|
|
||||||
|
Build with `go build -tags dev` for frontend hot-reload during development.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### SQLite readonly on NixOS
|
||||||
|
|
||||||
|
`SQLITE_READONLY_CANTINIT (1032)` means the database file is owned by root
|
||||||
|
due to `createHome` + `StateDirectory` interaction. Fix: add `chown` in the
|
||||||
|
systemd unit `preStart`. Also verify the directory is writable with a
|
||||||
|
`SELECT 1` ping in `newSqliteStore`.
|
||||||
|
|
||||||
|
### Embedding API 404
|
||||||
|
|
||||||
|
Empty 404 from embedding API means the URL path is wrong. Verify
|
||||||
|
`EMBEDDING_BASE_URL` is set correctly — the server normalizes the base URL
|
||||||
|
and appends `/v1/embeddings`. For LiteLLM proxy, leave it bare:
|
||||||
|
`EMBEDDING_BASE_URL=https://llm.aviron.ai`.
|
||||||
|
|
||||||
|
### MCP endpoint
|
||||||
|
|
||||||
|
The full endpoint is `https://st-pierre.home.all-dressed-programming.com/mcp`.
|
||||||
|
Auth via `Authorization: Bearer <token>` header. Token set via `MCP_TOKEN` env var.
|
||||||
+75
-30
@@ -5,9 +5,8 @@ const API = "/api/v1/flashcards";
|
|||||||
const Card = {
|
const Card = {
|
||||||
view: function (v) {
|
view: function (v) {
|
||||||
const c = v.attrs.card;
|
const c = v.attrs.card;
|
||||||
const maxNote = (c.note || "").length > 120
|
const maxNote =
|
||||||
? c.note.slice(0, 120) + "…"
|
(c.note || "").length > 120 ? c.note.slice(0, 120) + "…" : c.note;
|
||||||
: c.note;
|
|
||||||
return m(
|
return m(
|
||||||
".cyber-card.cyber-card--interactive.cyber-neon-border.cyber-mb-md",
|
".cyber-card.cyber-card--interactive.cyber-neon-border.cyber-mb-md",
|
||||||
{
|
{
|
||||||
@@ -18,12 +17,17 @@ const Card = {
|
|||||||
c.tags?.length
|
c.tags?.length
|
||||||
? m(
|
? m(
|
||||||
".cyber-card__header",
|
".cyber-card__header",
|
||||||
c.tags.map((t) => m("span.cyber-badge.cyber-badge--pulse.cyber-mr-xs", t)),
|
c.tags.map((t) =>
|
||||||
|
m("span.cyber-badge.cyber-badge--pulse.cyber-mr-xs", t),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
m(".cyber-card__body", [
|
m(".cyber-card__body", [
|
||||||
m(".cyber-text-muted.cyber-font-mono", `#${c.id} v${c.version}`),
|
m(".cyber-text-muted.cyber-font-mono", `#${c.id} v${c.version}`),
|
||||||
m("p.cyber-text-primary.cyber-mt-sm", maxNote || m("em.cyber-text-muted", "no content")),
|
m(
|
||||||
|
"p.cyber-text-primary.cyber-mt-sm",
|
||||||
|
maxNote || m("em.cyber-text-muted", "no content"),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -36,19 +40,24 @@ const Pager = {
|
|||||||
view: function (v) {
|
view: function (v) {
|
||||||
const { page, total } = v.attrs;
|
const { page, total } = v.attrs;
|
||||||
if (total <= 1) return null;
|
if (total <= 1) return null;
|
||||||
return m(".cyber-flex.cyber-justify-center.cyber-items-center.cyber-gap-sm.cyber-my-md", [
|
return m(
|
||||||
m(
|
".cyber-flex.cyber-justify-center.cyber-items-center.cyber-gap-sm.cyber-my-md",
|
||||||
"button.cyber-btn.cyber-btn--sm" + (page <= 1 ? ".cyber-btn--ghost" : ""),
|
[
|
||||||
{ disabled: page <= 1, onclick: () => v.attrs.onpage(page - 1) },
|
m(
|
||||||
"\u2190",
|
"button.cyber-btn.cyber-btn--sm" +
|
||||||
),
|
(page <= 1 ? ".cyber-btn--ghost" : ""),
|
||||||
m("span.cyber-font-mono.cyber-text-muted", `${page} / ${total}`),
|
{ disabled: page <= 1, onclick: () => v.attrs.onpage(page - 1) },
|
||||||
m(
|
"\u2190",
|
||||||
"button.cyber-btn.cyber-btn--sm" + (page >= total ? ".cyber-btn--ghost" : ""),
|
),
|
||||||
{ disabled: page >= total, onclick: () => v.attrs.onpage(page + 1) },
|
m("span.cyber-font-mono.cyber-text-muted", `${page} / ${total}`),
|
||||||
"\u2192",
|
m(
|
||||||
),
|
"button.cyber-btn.cyber-btn--sm" +
|
||||||
]);
|
(page >= total ? ".cyber-btn--ghost" : ""),
|
||||||
|
{ disabled: page >= total, onclick: () => v.attrs.onpage(page + 1) },
|
||||||
|
"\u2192",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,13 +86,20 @@ const List = {
|
|||||||
m(Pager, {
|
m(Pager, {
|
||||||
page,
|
page,
|
||||||
total: totalPages,
|
total: totalPages,
|
||||||
onpage: (n) => { v.state.page = n; },
|
onpage: (n) => {
|
||||||
|
v.state.page = n;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
m(".cyber-scanlines", slice.map((c) => m(Card, { card: c }))),
|
m(
|
||||||
|
".cyber-scanlines",
|
||||||
|
slice.map((c) => m(Card, { card: c })),
|
||||||
|
),
|
||||||
m(Pager, {
|
m(Pager, {
|
||||||
page,
|
page,
|
||||||
total: totalPages,
|
total: totalPages,
|
||||||
onpage: (n) => { v.state.page = n; },
|
onpage: (n) => {
|
||||||
|
v.state.page = n;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
@@ -107,12 +123,20 @@ const Detail = {
|
|||||||
const e = v.state.entry;
|
const e = v.state.entry;
|
||||||
if (!e) {
|
if (!e) {
|
||||||
return m(".cyber-p-lg", { style: "max-width:720px;margin:0 auto" }, [
|
return m(".cyber-p-lg", { style: "max-width:720px;margin:0 auto" }, [
|
||||||
m("button.cyber-btn.cyber-btn--ghost", { onclick: () => m.route.set("/") }, "\u2B05 Back"),
|
m(
|
||||||
|
"button.cyber-btn.cyber-btn--ghost",
|
||||||
|
{ onclick: () => m.route.set("/") },
|
||||||
|
"\u2B05 Back",
|
||||||
|
),
|
||||||
m("p.cyber-text-magenta", "Flashcard not found."),
|
m("p.cyber-text-magenta", "Flashcard not found."),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
return m(".cyber-p-lg", { style: "max-width:720px;margin:0 auto" }, [
|
return m(".cyber-p-lg", { style: "max-width:720px;margin:0 auto" }, [
|
||||||
m("button.cyber-btn.cyber-btn--ghost.cyber-mb-md", { onclick: () => m.route.set("/") }, "\u2B05 Back"),
|
m(
|
||||||
|
"button.cyber-btn.cyber-btn--ghost.cyber-mb-md",
|
||||||
|
{ onclick: () => m.route.set("/") },
|
||||||
|
"\u2B05 Back",
|
||||||
|
),
|
||||||
m(".cyber-card.cyber-card--holo", [
|
m(".cyber-card.cyber-card--holo", [
|
||||||
m(".cyber-card__header", [
|
m(".cyber-card__header", [
|
||||||
m("span.cyber-font-mono.cyber-text-cyan", `#${e.id} v${e.version}`),
|
m("span.cyber-font-mono.cyber-text-cyan", `#${e.id} v${e.version}`),
|
||||||
@@ -120,29 +144,50 @@ const Detail = {
|
|||||||
m(".cyber-card__body", [
|
m(".cyber-card__body", [
|
||||||
m(".cyber-field", [m(".cyber-field__label", "Note"), m("p", e.note)]),
|
m(".cyber-field", [m(".cyber-field__label", "Note"), m("p", e.note)]),
|
||||||
e.host
|
e.host
|
||||||
? m(".cyber-field.cyber-mt-sm", [m(".cyber-field__label", "Host"), m("p", e.host)])
|
? m(".cyber-field.cyber-mt-sm", [
|
||||||
|
m(".cyber-field__label", "Host"),
|
||||||
|
m("p", e.host),
|
||||||
|
])
|
||||||
: null,
|
: null,
|
||||||
e.user
|
e.user
|
||||||
? m(".cyber-field.cyber-mt-sm", [m(".cyber-field__label", "User"), m("p", e.user)])
|
? m(".cyber-field.cyber-mt-sm", [
|
||||||
|
m(".cyber-field__label", "User"),
|
||||||
|
m("p", e.user),
|
||||||
|
])
|
||||||
: null,
|
: null,
|
||||||
e.context
|
e.context
|
||||||
? m(".cyber-field.cyber-mt-sm", [m(".cyber-field__label", "Context"), m("p", e.context)])
|
? m(".cyber-field.cyber-mt-sm", [
|
||||||
|
m(".cyber-field__label", "Context"),
|
||||||
|
m("p", e.context),
|
||||||
|
])
|
||||||
: null,
|
: null,
|
||||||
e.repos?.length
|
e.repos?.length
|
||||||
? m(".cyber-field.cyber-mt-sm", [
|
? m(".cyber-field.cyber-mt-sm", [
|
||||||
m(".cyber-field__label", "Repos"),
|
m(".cyber-field__label", "Repos"),
|
||||||
m("div", e.repos.map((r) => m("span.cyber-badge.cyber-mr-xs", r))),
|
m(
|
||||||
|
"div",
|
||||||
|
e.repos.map((r) => m("span.cyber-badge.cyber-mr-xs", r)),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
: null,
|
: null,
|
||||||
e.tags?.length
|
e.tags?.length
|
||||||
? m(".cyber-field.cyber-mt-sm", [
|
? m(".cyber-field.cyber-mt-sm", [
|
||||||
m(".cyber-field__label", "Tags"),
|
m(".cyber-field__label", "Tags"),
|
||||||
m("div", e.tags.map((t) => m("span.cyber-badge.cyber-mr-xs", t))),
|
m(
|
||||||
|
"div",
|
||||||
|
e.tags.map((t) => m("span.cyber-badge.cyber-mr-xs", t)),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
: null,
|
: null,
|
||||||
m(".cyber-field.cyber-mt-sm", [m(".cyber-field__label", "Created"), m("p", e.created)]),
|
m(".cyber-field.cyber-mt-sm", [
|
||||||
|
m(".cyber-field__label", "Created"),
|
||||||
|
m("p", e.created),
|
||||||
|
]),
|
||||||
e.delete_timestamp
|
e.delete_timestamp
|
||||||
? m(".cyber-field.cyber-mt-sm", [m(".cyber-field__label", "Deleted"), m("p", e.delete_timestamp)])
|
? m(".cyber-field.cyber-mt-sm", [
|
||||||
|
m(".cyber-field__label", "Deleted"),
|
||||||
|
m("p", e.delete_timestamp),
|
||||||
|
])
|
||||||
: null,
|
: null,
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
|
|||||||
Reference in new issue
Block a user