Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
647da64
[S1] Add the shared memory walking skeleton on SQLite with every seam
dicko2 Oct 2, 2026
363285b
[S2] Run memory on Postgres from shipped reference SQL with a startup…
dicko2 Oct 2, 2026
1b6b4a6
[S2] Run memory tests against a Postgres service in CI and add the me…
dicko2 Oct 2, 2026
d483803
[S2] Document shared agent memory in the README
dicko2 Oct 2, 2026
801fb89
[S6] Confirm exact duplicates, honour same_as/supersedes and prompt o…
dicko2 Oct 2, 2026
4b38752
[S8] Refuse secrets, agent-addressed text, tool-call syntax and comma…
dicko2 Oct 2, 2026
105d71e
[S8] Ship the full memory instructions block and end-to-end safety tests
dicko2 Oct 2, 2026
7c1fb3d
[S7] Add end-to-end memory_report tests for keyed, unkeyed and dedupl…
dicko2 Oct 2, 2026
3f8a7fa
[S7] Dedupe memory reports and store candidate keys for unkeyed reports
dicko2 Oct 2, 2026
84cfac1
[S7] Expect candidate keys on unkeyed reports in the skeleton test
dicko2 Oct 2, 2026
b978316
[S3] Add keyword recall tests for exact tokens on both backends
dicko2 Oct 2, 2026
7d1bde5
[S3] Rank memories with FTS5 bm25 and Postgres ts_rank
dicko2 Oct 2, 2026
5867e02
[S4] Rank recall by scope and quality within the character budget
dicko2 Oct 2, 2026
110b148
[S9] Load host vocabulary from YAML/JSON files
dicko2 Oct 2, 2026
6f894ce
[S5] Find paraphrased memories by cosine over stored embeddings
dicko2 Oct 2, 2026
01b12e4
[S8] Split entropy tokens on path, URL and kebab separators so links,…
dicko2 Oct 2, 2026
c58e15a
[S8] Catch SECRET_KEY/private_key assignments and Python-dict tool ca…
dicko2 Oct 2, 2026
177b427
[S10] Add memory admin routes for list, reports, stats and delete
dicko2 Oct 2, 2026
2824460
[S10] Add the memory catalog page
dicko2 Oct 2, 2026
850f964
[S5] Don't assert the no-embedder scope fallback
dicko2 Oct 2, 2026
4d135e8
[S5] Skip non-byte embeddings and match cosine on short vectors
dicko2 Oct 2, 2026
1feb1a4
[S4] Return no memories when a query has no ranked hits
dicko2 Oct 2, 2026
67044a0
[S4] Drop the recall import cycle and default a bad limit to 5
dicko2 Oct 2, 2026
a6c7788
[S10] Add memory page snapshot tests for list, empty, hidden filter, …
dicko2 Oct 2, 2026
7ee935c
[S5] Backfill missing and other-model embeddings from the store
dicko2 Oct 2, 2026
f68e426
[S8] Refuse home-directory paths in memory and report text
dicko2 Oct 2, 2026
1a7995c
[S8] Ship an optional shared-memory skill and document backfill
dicko2 Oct 2, 2026
8d77729
[Review] Keep digits in memory fingerprints and honour supersedes bef…
dicko2 Oct 2, 2026
94d0f62
[Review] Require the identity header on memory admin read routes
dicko2 Oct 2, 2026
200e322
[Review] Reject multi-line and overlong context fields, lint them, an…
dicko2 Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ on:
jobs:
test-python:
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: pg
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 2s
--health-timeout 5s
--health-retries 15
steps:
- uses: actions/checkout@v4

Expand All @@ -29,7 +41,7 @@ jobs:
python-version: "3.12"

- name: Install package
run: pip install -e ".[dev,otel]"
run: pip install -e ".[dev,otel,memory]"

- name: Verify SPA is packaged
run: |
Expand All @@ -44,6 +56,8 @@ jobs:
run: ruff check src

- name: pytest
env:
DROPMCP_TEST_POSTGRES_URL: postgresql+psycopg://postgres:pg@localhost:5432/postgres
run: pytest

test-ui:
Expand Down Expand Up @@ -97,6 +111,9 @@ jobs:
- name: Build wheel
run: pip install build && python -m build

- name: Verify memory SQL is packaged
run: python -m zipfile -l dist/*.whl | grep -q "dropmcp/memory/sql/memory.sql"

- name: Upload wheel artifact
uses: actions/upload-artifact@v4
with:
Expand Down
150 changes: 149 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ skills/prompts MCP servers, extracted as a standalone library.
agents get corrected.
- **Repository feedback backlog** — an optional `record_repo_feedback` tool and
triage UI for flaky tests, CI-only checks, setup gaps, and other repo friction.
- **Shared agent memory** — optional `memory_remember` / `memory_recall` /
`memory_report` tools so agents pass short, reusable working facts about repos
and stacks to the next agent.
- **Benchmarks page** — an optional models × skills matrix of the latest E2E
eval results, shown only to identified users.
- **Per-user subscriptions** — optional opt-in so each user's agent sees only a
Expand Down Expand Up @@ -76,6 +79,12 @@ Optional OpenTelemetry export:
pip install "dropmcp[otel]"
```

Optional numpy for faster vector scoring in [shared agent memory](#shared-agent-memory):

```bash
pip install "dropmcp[memory]"
```

## Quick start

Lay out your content:
Expand Down Expand Up @@ -147,10 +156,16 @@ default).
| `ui_enabled` | `DROPMCP_UI` | `true` | serve the catalog HTTP routes |
| `feedback_enabled` | `DROPMCP_FEEDBACK` | `true` | enable the `record_feedback` tool, feedback HTTP routes, and always-on instructions |
| `repo_feedback_enabled` | `DROPMCP_REPO_FEEDBACK` | `false` | enable the optional `record_repo_feedback` tool, repo feedback HTTP routes, and always-on instructions |
| `memory_enabled` | `DROPMCP_MEMORY` | `false` | enable the [shared agent memory](#shared-agent-memory) tools and always-on instructions |
| `memory_embedder` | – | – | `MemoryEmbedder` for vector recall and the near-duplicate check; keyword-only when unset |
| `memory_vocabulary` | `DROPMCP_MEMORY_VOCABULARY` | bundled list | YAML/JSON file (or dict) of languages, stack tags, domains, kinds and tasks |
| `memory_lint_rules` | – | – | extra write-side checks for memory and report text |
| `memory_store` | – | built from `database_url` | replace the memory store entirely |
| `memory_candidate_cap` | `DROPMCP_MEMORY_CANDIDATE_CAP` | `500` | max candidates scored per recall or near-duplicate check |
| `user_subscriptions_enabled` | `DROPMCP_USER_SUBSCRIPTIONS` | `false` | per-user skill/prompt opt-in over MCP and subscription HTTP API |
| `user_header` | `DROPMCP_USER_HEADER` | `X-User-Email` | HTTP header carrying the trusted caller identity |
| `reload` | `DROPMCP_RELOAD` | `false` | re-scan skills/prompts on every request |
| `database_url` | `DROPMCP_DATABASE_URL` | `sqlite:///<cwd>/dropmcp.db` | feedback database tables (SQLite file or Postgres URL) |
| `database_url` | `DROPMCP_DATABASE_URL` | `sqlite:///<cwd>/dropmcp.db` | feedback and memory database tables (SQLite file or Postgres URL) |
| `eval_results_project` | `DROPMCP_EVAL_RESULTS_PROJECT` | – | project path for E2E eval results (enables `/api/telemetry` when a store is available) |
| `eval_results_commit_sha` | `DROPMCP_EVAL_RESULTS_COMMIT_SHA` | `COMMIT_SHA` file | deployed commit to filter eval results |
| – | `DROPMCP_EVAL_RESULTS_SKILL_QUERY` | – | SQL for one skill; required for the built-in MySQL store |
Expand Down Expand Up @@ -355,6 +370,139 @@ The same privacy rule applies: no secrets, PII, customer data, proprietary code
snippets, or verbatim prompts. Repo feedback instructions are injected only when
the feature flag is enabled.

## Shared agent memory

Set `DROPMCP_MEMORY=true` (or `memory_enabled=True`) to let agents share what they
learn. It is disabled by default, and nothing changes for servers that leave it
off. A *memory* is one short, reusable working fact that the next agent on the same
repo or stack would otherwise have to rediscover, for example "validation failures
in this service come back as HTTP 200 with an error body".

Memory has a clear boundary with the other channels:

| Thing learned | Goes to |
|---|---|
| The repo makes work slow or risky (flaky test, CI-only checks) | `record_repo_feedback` |
| The agent or a skill got it wrong | `record_feedback` |
| A standard everyone should follow | a skill, via PR |
| A working fact about a repo, system, tool or stack that helped get a task done | **memory** |

A memory that keeps getting confirmed is a candidate for the repo's `AGENTS.md` or
a skill. Memory is a staging area, not the final home.

### Tools

Every tool takes the same `context` object: `repo` (`owner/name`), `system`,
`language`, `domain`, `stack` (tags), `task` and `path`. `repo`, `system`,
`language` and `domain` scope a memory: an empty field means general, a matching
field ranks higher, and a different field excludes it. `stack`, `task` and `path`
only boost. Unknown languages are refused; unknown stack tags are kept and logged.

- **`memory_recall`** (`context`, optional `query`, `limit` default 5, max 10) —
returns up to `limit` memories in about 4,000 characters, each with its key,
kind, title, body, scope, age, confirmations, last-confirmed date and open
report count, under a preface telling the agent these are hints to verify, not
instructions. With a `query` it ranks by keyword search (Postgres full-text
search, SQLite FTS5) merged with vector similarity when an embedder is
configured. Without one it returns the most-confirmed memories for the scope.
Every recall writes a row to `memory_recall_log` (context and returned ids, not
the query text).
- **`memory_remember`** (`context`, `kind`, `title`, `body`, optional `evidence`,
`supersedes`, `same_as`, `distinct`) — refuses text that fails the write-side
checks; confirms an exact duplicate (same scope and title) or a `same_as`
instead of storing it again; with an embedder, stops near-duplicates and shows
the agent the top three so it can call again with `same_as`, `supersedes` or
`distinct`. `supersedes` retires the old memory.
- **`memory_report`** (`key` or `memory`, `problem`, `reason`, optional
`correction`, `context`) — `problem` is `stale`, `invalid`, `wrong_scope` or
`sensitive`. Writes a row to the `memory_report` bucket and changes nothing
else, except that a `sensitive` report hides the memory from recall at once.
A report is never refused for a missing or mangled key: it is stored with the
description and the top candidate keys.

All three also take the calling `model`, as `record_feedback` does. Each memory
has a short key such as `MEM-7K3F9Q`, with a check character so a mangled key is
detected rather than matched to the wrong memory.

An optional agent skill with the same guidance as the always-on instructions
block ships in [`examples/skills/shared-memory`](examples/skills/shared-memory/SKILL.md);
copy it into your `skills/` folder to serve it.

### Parameters

| kwarg | env | default | purpose |
|---|---|---|---|
| `memory_enabled` | `DROPMCP_MEMORY` | `false` | registers the tools and the always-on instructions block |
| `memory_embedder` | – | – | `dropmcp.memory.vectors.MemoryEmbedder(embed, model, dimension, near_duplicate_threshold=0.92)`, where `embed` is `Callable[[list[str]], list[list[float]]]`. Unset means keyword-only search and no near-duplicate check |
| `memory_vocabulary` | `DROPMCP_MEMORY_VOCABULARY` | bundled generic list | path to a YAML/JSON file (or a dict) with languages, stack tags, domains, kinds and tasks; shown to agents as enums in the tool schemas |
| `memory_lint_rules` | – | – | extra write-side checks, each `Callable[[str], str \| None]` returning a refusal reason, run after the built-in ones |
| `memory_store` | – | built from `database_url` | replace the store entirely |
| `memory_candidate_cap` | `DROPMCP_MEMORY_CANDIDATE_CAP` | `500` | max candidates scored per recall or near-duplicate check |

Embeddings are packed float32 bytes in an ordinary binary column, and similarity
is scored in-process over the filtered candidates, so no vector extension is
needed on any database. Install `dropmcp[memory]` to score with numpy; without
it a pure-Python fallback is used. If the embedder fails, the memory is still
stored and found by keyword search.

Rows with no embedding, or one from a different model or dimension, are skipped
by vector scoring until they are backfilled. Changing embedder model is a
backfill, not a migration. Run it from a scheduled job or at startup:

```python
from dropmcp.memory.store import MemoryStore

embedded = MemoryStore(database_url).backfill_embeddings(embedder)
```

It embeds active memories in batches (`batch_size=100`) and stops at the first
failed batch, leaving the rest for the next run.

### Storage

Memory uses the same `DROPMCP_DATABASE_URL` as feedback, in four tables:
`memory`, `memory_report`, `memory_recall_log` and `memory_near_duplicate_log`.
SQLite creates them automatically. dropmcp never runs DDL against Postgres; apply
the reference SQL it ships with your migration tool before turning memory on:

```bash
python -m dropmcp memory-sql > memory.sql
psql "$DATABASE_URL" -f memory.sql
```

The SQL needs no extensions, uses unqualified table names (so it lands in the
first schema on your `search_path`), and is safe to apply more than once. On
startup, a server pointed at Postgres checks the live schema and refuses to start
with a message naming every missing table and column, instead of failing on the
first tool call. The check is skipped when you pass your own `memory_store`.

Servers that share a `database_url` and vocabulary file share one memory pool.
That's allowed but not specially designed for: the `server` column records which
server wrote each memory, and values from another server's vocabulary are still
recalled.

### What never to store

The always-on instructions tell agents, and the write-side checks enforce where
they can, that memory and report text must not contain:

- secrets, tokens or connection strings with credentials (refused)
- personal or customer data, or anything about a person (performance, ratings,
compensation, leave, health), or people and team moves
- verbatim proprietary code beyond a line or two, or verbatim user prompts
- confidential business information that isn't engineering knowledge
- local filesystem paths and machine-specific detail (home-directory paths are
refused)

The checks also refuse text addressed to the reading agent ("ignore previous",
"you must", tool-call syntax) and bodies that are mostly a command to run. The
rule of thumb: memory is for how systems, repos and tools behave. If a fact is
about a person, or would be out of place in a repo's README, it doesn't belong.

To run the memory tests against Postgres too, set `DROPMCP_TEST_POSTGRES_URL`
(for example `postgresql+psycopg://postgres:pg@localhost:5432/postgres`). Each
test gets its own schema; without the variable the Postgres cases are skipped.

## Trusted user identity

When dropmcp is deployed behind an authentication proxy, set the trusted caller
Expand Down
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import DetailPage from './pages/DetailPage';
import FeedbackPage from './pages/FeedbackPage';
import RepoFeedbackPage from './pages/RepoFeedbackPage';
import BenchmarksPage from './pages/BenchmarksPage';
import MemoryPage from './pages/MemoryPage';

const queryClient = new QueryClient();

Expand All @@ -22,6 +23,7 @@ export default function App() {
<Route path="/feedback" element={<FeedbackPage />} />
<Route path="/repo-feedback" element={<RepoFeedbackPage />} />
<Route path="/benchmarks" element={<BenchmarksPage />} />
<Route path="/memory" element={<MemoryPage />} />
<Route path="/:type/:name" element={<DetailPage />} />
</Routes>
<Footer />
Expand Down
147 changes: 147 additions & 0 deletions client/src/api/memory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
export type MemorySort = 'confirmations' | 'recent';
export type MemoryStatus = 'active' | 'superseded' | 'retired';

export const MEMORY_STATUSES: MemoryStatus[] = ['active', 'superseded', 'retired'];

export interface MemoryListItem {
key: string;
title: string;
kind: string;
status: string;
repo: string | null;
language: string | null;
scope: string;
confirmations: number;
confirmations_label: string;
open_reports: number;
open_reports_label: string;
hidden: boolean;
hidden_label: string | null;
display_created_at: string | null;
display_confirmed_at: string | null;
}

export interface MemoryDetail extends MemoryListItem {
body: string;
evidence: string | null;
}

export interface MemoryReport {
id: string;
problem: string;
problem_label: string;
reason: string | null;
correction: string | null;
status: string;
reported_key: string | null;
memory_key: string | null;
described_memory: string | null;
candidate_keys: string[];
candidate_keys_label: string | null;
keyed: boolean;
link_label: string;
display_created_at: string | null;
}

export interface MemoryStatsPeriod {
days: number;
label: string;
recall_count: number;
recalls_with_results: number;
recalls_without_results: number;
share_returning: string;
report_count: number;
reports_per_recall: string;
distinct_writers: number;
}

export interface MemoryListResponse {
items: MemoryListItem[];
repos: string[];
languages: string[];
kinds: string[];
}

export interface MemoryDetailResponse {
memory: MemoryDetail;
reports: MemoryReport[];
}

export interface MemoryReportsResponse {
items: MemoryReport[];
}

export interface MemoryStatsResponse {
periods: MemoryStatsPeriod[];
}

export interface MemoryFilters {
search?: string;
repo?: string;
language?: string;
kind?: string;
status?: MemoryStatus;
hidden?: boolean;
sort?: MemorySort;
}

const SIGN_IN_TO_VIEW = 'Sign in to view memories.';

function buildQuery(filters: MemoryFilters): string {
const params = new URLSearchParams();
if (filters.search) params.set('search', filters.search);
if (filters.repo) params.set('repo', filters.repo);
if (filters.language) params.set('language', filters.language);
if (filters.kind) params.set('kind', filters.kind);
if (filters.status) params.set('status', filters.status);
if (filters.hidden) params.set('hidden', 'true');
if (filters.sort) params.set('sort', filters.sort);
const qs = params.toString();
return qs ? `?${qs}` : '';
}

export async function fetchMemories(
filters: MemoryFilters = {},
): Promise<MemoryListResponse> {
const res = await fetch(`/api/memory${buildQuery(filters)}`);
if (res.status === 401) throw new Error(SIGN_IN_TO_VIEW);
if (!res.ok) throw new Error(`Could not load memories (${res.status}).`);
const data: MemoryListResponse = await res.json();
return {
items: Array.isArray(data.items) ? data.items : [],
repos: Array.isArray(data.repos) ? data.repos : [],
languages: Array.isArray(data.languages) ? data.languages : [],
kinds: Array.isArray(data.kinds) ? data.kinds : [],
};
}

export async function fetchMemory(key: string): Promise<MemoryDetailResponse> {
const res = await fetch(`/api/memory/${encodeURIComponent(key)}`);
if (res.status === 401) throw new Error(SIGN_IN_TO_VIEW);
if (!res.ok) throw new Error(`Could not load memory (${res.status}).`);
return res.json();
}

export async function fetchOpenReports(): Promise<MemoryReport[]> {
const res = await fetch('/api/memory/reports');
if (res.status === 401) throw new Error(SIGN_IN_TO_VIEW);
if (!res.ok) throw new Error(`Could not load reports (${res.status}).`);
const data: MemoryReportsResponse = await res.json();
return Array.isArray(data.items) ? data.items : [];
}

export async function fetchMemoryStats(): Promise<MemoryStatsResponse> {
const res = await fetch('/api/memory/stats');
if (res.status === 401) throw new Error(SIGN_IN_TO_VIEW);
if (!res.ok) throw new Error(`Could not load recall stats (${res.status}).`);
const data: MemoryStatsResponse = await res.json();
return { periods: Array.isArray(data.periods) ? data.periods : [] };
}

export async function deleteMemory(key: string): Promise<void> {
const res = await fetch(`/api/memory/${encodeURIComponent(key)}`, {
method: 'DELETE',
});
if (res.status === 401) throw new Error('Sign in to delete a memory.');
if (!res.ok) throw new Error(`Could not delete memory (${res.status}).`);
}
Loading
Loading