@tpsdev-ai/flair 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/n8n.md ADDED
@@ -0,0 +1,122 @@
1
+ # Flair + n8n
2
+
3
+ Use Flair as the memory backend for n8n's AI Agent. The same memories are readable from Claude Code, OpenClaw, and any other Flair client — that's the point.
4
+
5
+ ## When to use Flair vs n8n's built-in memory connectors
6
+
7
+ n8n ships memory connectors for Postgres, MongoDB, and Redis. Those are real, persistent, and work fine for **conversation-buffer** use cases inside a single n8n instance.
8
+
9
+ Flair is the right pick when you want:
10
+
11
+ | | Flair | n8n built-ins |
12
+ |---|---|---|
13
+ | **Shape** | Tagged + typed memories with semantic search, plus chat-buffer compatibility | Conversation-buffer only (LangChain `BaseMessage` records) |
14
+ | **Cross-orchestrator** | Same memory readable from Claude Code, OpenClaw, n8n | n8n-internal schema; nothing else reads it |
15
+ | **Cross-instance** | Hub-spoke federation built-in (local ↔ Fabric, etc.) | Single-instance unless you self-build replication |
16
+ | **Identity** | Ed25519 per-agent (planned) or admin-token (v1) | n8n credential per workflow |
17
+
18
+ If your AI Agent only needs to remember the last N turns of a single chat in a single n8n instance, Postgres-as-memory is fine. If you want the same memory to inform a Claude Code conversation tomorrow, or to persist across n8n redeploys via federated Flair, this package is the path.
19
+
20
+ ## Setup (5 minutes)
21
+
22
+ ### 1. Install Flair (if not already running)
23
+
24
+ ```bash
25
+ npm install -g @tpsdev-ai/flair
26
+ flair init
27
+ ```
28
+
29
+ This starts a local Flair server on `http://localhost:19926`. For shared/team setups, see [Deployment](./deployment.md).
30
+
31
+ ### 2. Install the n8n community node
32
+
33
+ In n8n: **Settings → Community Nodes → Install** → enter `@tpsdev-ai/n8n-nodes-flair` → confirm and restart.
34
+
35
+ ### 3. Create the credential
36
+
37
+ In n8n: **Credentials → New → Flair API**. Fill in:
38
+
39
+ | Field | Value |
40
+ |---|---|
41
+ | **Base URL** | `http://localhost:19926` (or your team's Flair URL) |
42
+ | **Agent ID** | Logical identity that will own memories from this n8n workspace. Workflows that share an Agent ID share memory ownership. Use distinct IDs when isolation matters. |
43
+ | **Admin Password** | Your Flair admin password (in `~/.flair/admin-pass` for local installs). **Sensitive** — see [Security](#security). |
44
+
45
+ Click **Test** — if the test request to `/Memory` returns 200, the credential is good.
46
+
47
+ ### 4. Wire the nodes
48
+
49
+ Three nodes ship in the package:
50
+
51
+ - **Flair Chat Memory** — connects to an AI Agent's `Memory` socket. Stores chat history in Flair, scoped by Subject. Defaults to per-workflow memory; set the optional Session Sub-Key to `={{ $execution.id }}` for per-run isolation.
52
+ - **Flair Search** — connects to an AI Agent's `Tool` socket. Two operations:
53
+ - *Semantic Search* — agent calls `flair_search({ query })`, gets memories ranked by similarity.
54
+ - *Get By Subject* — agent calls `flair_get_by_subject()`, gets memories under a config-time-bound subject.
55
+ - **Flair Write** — a regular Main-input/Main-output pipeline node (not wired to the AI Agent's Tool socket). Takes an incoming item and writes its content as a Flair memory, with Subject, Tags, and Durability fields. For operator-driven capture-and-archive flows (mail → memory, webhook → memory, parsed-doc → memory) where the write is the workflow author's choice, not the LLM's.
56
+
57
+ A typical workflow:
58
+
59
+ ```
60
+ [Webhook] → [AI Agent]
61
+ ├─ Model: Claude / OpenAI / etc.
62
+ ├─ Memory: Flair Chat Memory (Subject: customer-support)
63
+ ├─ Tool: Flair Search (Operation: Semantic Search)
64
+ └─ Tool: HTTP Request (etc.)
65
+ ```
66
+
67
+ The agent now answers using both its current chat history (from Flair Chat Memory) and any relevant historical memories it pulls in via Flair Search.
68
+
69
+ ## Subject and SessionId guidance
70
+
71
+ n8n memory connectors expose a `sessionKey` parameter that scopes the chat history. Flair has a richer model:
72
+
73
+ - **Subject** (required) — the entity / conversation / topic the memory is about. Indexed in Flair's schema; efficient to filter on. Default: `={{ $workflow.name }}`.
74
+ - **Session Sub-Key** (optional) — appended to the subject as `<subject>:<sessionKey>`. Use the n8n execution id (`={{ $execution.id }}`) for per-run isolation, or a customer/user id for per-customer scoping, or leave blank to share across runs.
75
+
76
+ Patterns:
77
+
78
+ - **"This assistant remembers"** — set Subject to a stable string (`customer-support`, `daily-standup`). Leave Session Sub-Key blank. All runs share memory.
79
+ - **Per-conversation isolation** — set Subject to the conversation owner (`customer:1234`), leave Session Sub-Key blank. Each conversation is isolated by subject.
80
+ - **Per-execution isolation** — set Session Sub-Key to `={{ $execution.id }}`. Each n8n run gets its own memory window. (This is most similar to n8n's default `sessionKey={{ $execution.id }}`.)
81
+
82
+ ## Security
83
+
84
+ > **The admin password gives every workflow with this credential read/write access to the entire Flair instance**, not just the configured Agent ID. The blast radius is the whole memory store. Treat the credential as highly sensitive: n8n encrypts credentials at rest, but any n8n admin or backup restore can extract it.
85
+
86
+ For production deployments where untrusted workflow inputs reach Flair, wait for **Ed25519 per-agent authentication** (planned). v1 (admin password) is appropriate when:
87
+
88
+ - The n8n instance is single-tenant and operator-controlled
89
+ - Workflow inputs are trusted (your own CRM, your own webhook source)
90
+ - Memory leakage between agents is acceptable for the use case
91
+
92
+ If any of those don't hold, use Flair's CLI / SDK clients (which support per-agent Ed25519 today) and wait for the n8n credential update.
93
+
94
+ ## Get By Tag — coming soon
95
+
96
+ The Flair Search node currently exposes Semantic Search and Get By Subject. **Get By Tag** is deferred until `flair-client.memory.list` exposes a `tags` filter (tracked in the [n8n-node spec](https://github.com/tpsdev-ai/flair/blob/main/specs/N8N-NODE-q3qf.md) §6). Workaround for now: use Semantic Search and let the model filter results by tags in the response.
97
+
98
+ ## Worked examples
99
+
100
+ Two example workflows are coming in a follow-up release; they're authored inside a real n8n instance and round-tripped via Export so they import cleanly:
101
+
102
+ - **`chat-memory-demo.json`** — Webhook → AI Agent (Claude + Flair Chat Memory) → Respond. Demonstrates the conversation-buffer use case. Run twice with the same input to see memory replay.
103
+ - **`knowledge-search-demo.json`** — Schedule → AI Agent (Claude + Flair Chat Memory + Flair Search as Tool) → action. Demonstrates the structured-knowledge-search use case.
104
+
105
+ In the interim, follow the [Setup](#setup-5-minutes) and [Subject and SessionId guidance](#subject-and-sessionid-guidance) sections — wiring is straightforward without an example file.
106
+
107
+ ## Compared to other Flair surfaces
108
+
109
+ | Surface | Use case | Setup |
110
+ |---|---|---|
111
+ | [Claude Code](./claude-code.md) | Personal AI assistant memory across CLI sessions | npm install + `flair init` |
112
+ | [OpenClaw](./openclaw.md) | Multi-agent OpenClaw deployments | OpenClaw plugin install |
113
+ | [MCP](./mcp-clients.md) | Any MCP client (Claude Desktop, etc.) | MCP server registration |
114
+ | **n8n (this doc)** | Workflow-engine AI Agents | n8n community-node install |
115
+
116
+ Same Flair instance, same memories, different surfaces.
117
+
118
+ ## See also
119
+
120
+ - [Spec — `@tpsdev-ai/n8n-nodes-flair`](https://github.com/tpsdev-ai/flair/blob/main/specs/N8N-NODE-q3qf.md) — implementation plan, design decisions, anti-patterns
121
+ - [Bridges](./bridges.md) — how Flair memories flow between hosts and instances
122
+ - [Federation](./federation.md) — hub-and-spoke replication
@@ -0,0 +1,331 @@
1
+ # Google ADK Spike — Plugin Contract Findings
2
+
3
+ **Date:** 2026-05-05
4
+ **Investigator:** Ember
5
+ **Scope:** Verify plugin contract for a `flair-adk-py` adapter
6
+
7
+ ---
8
+
9
+ ## Q1: ADK Basics — Exists? Open-source? Python?
10
+
11
+ **Yes.** Google ADK is a real, open-source, Python-first framework.
12
+
13
+ | Field | Value |
14
+ |---|---|
15
+ | **Package** | `google-adk` on PyPI |
16
+ | **Current version** | 1.32.0 (released 2026-04-30) |
17
+ | **Repo** | https://github.com/google/adk-python |
18
+ | **License** | Apache 2.0 |
19
+ | **Docs** | https://google.github.io/adk-docs/ |
20
+ | **Language** | Python (primary). Java, Go, and TypeScript ports exist at `google/adk-java`, `google/adk-go`, `waldzellai/adk-typescript` |
21
+ | **Samples** | https://github.com/google/adk-samples |
22
+
23
+ From `pyproject.toml`:
24
+ ```toml
25
+ name = "google-adk"
26
+ ```
27
+
28
+ ---
29
+
30
+ ## Q2: Memory Contract — Abstract Class for Custom Backends
31
+
32
+ ADK defines `BaseMemoryService(ABC)` in `google/adk/memory/base_memory_service.py`. This is the contract custom memory backends implement — equivalent to LangChain's `BaseChatMemory` or n8n's `BaseListChatMessageHistory`.
33
+
34
+ ### Interface signature
35
+
36
+ ```python
37
+ class BaseMemoryService(ABC):
38
+ """Base class for memory services."""
39
+
40
+ # --- REQUIRED (abstract) methods ---
41
+
42
+ @abstractmethod
43
+ async def add_session_to_memory(self, session: Session) -> None:
44
+ """Ingest a full session's events into memory.
45
+
46
+ Called periodically or on-demand to persist conversation history.
47
+ Session may be added multiple times during its lifetime.
48
+ """
49
+
50
+ @abstractmethod
51
+ async def search_memory(
52
+ self, *,
53
+ app_name: str,
54
+ user_id: str,
55
+ query: str,
56
+ ) -> SearchMemoryResponse:
57
+ """Semantic/text search over ingested memories for a user.
58
+
59
+ Returns SearchMemoryResponse containing list[MemoryEntry].
60
+ """
61
+
62
+ # --- OPTIONAL (default: raise NotImplementedError) ---
63
+
64
+ async def add_events_to_memory(
65
+ self, *,
66
+ app_name: str,
67
+ user_id: str,
68
+ events: Sequence[Event],
69
+ session_id: str | None = None,
70
+ custom_metadata: Mapping[str, object] | None = None,
71
+ ) -> None:
72
+ """Incremental delta: add a subset of events, not the full session.
73
+
74
+ Implementations should treat `events` as incremental, not replacing
75
+ the full session. custom_metadata keys are service-specific.
76
+ """
77
+
78
+ async def add_memory(
79
+ self, *,
80
+ app_name: str,
81
+ user_id: str,
82
+ memories: Sequence[MemoryEntry],
83
+ custom_metadata: Mapping[str, object] | None = None,
84
+ ) -> None:
85
+ """Direct write: add explicit MemoryEntry objects (not from events).
86
+
87
+ For services that support writing memory facts directly.
88
+ """
89
+ ```
90
+
91
+ ### Key data types
92
+
93
+ ```python
94
+ class MemoryEntry(BaseModel):
95
+ """Represent one memory entry."""
96
+ content: types.Content # google.genai.types.Content (parts: text, inline_data, etc.)
97
+ custom_metadata: dict[str, Any] = Field(default_factory=dict)
98
+ id: Optional[str] = None
99
+ author: Optional[str] = None
100
+ timestamp: Optional[str] = None # ISO 8601 preferred
101
+
102
+ class SearchMemoryResponse(BaseModel):
103
+ memories: list[MemoryEntry] = Field(default_factory=list)
104
+
105
+ class Session(BaseModel):
106
+ id: str
107
+ app_name: str
108
+ user_id: str
109
+ state: dict[str, Any]
110
+ events: list[Event] # conversation events
111
+ last_update_time: float
112
+ ```
113
+
114
+ ### Scoping model
115
+
116
+ All memory operations are scoped to `(app_name, user_id)`. There is no global memory. `session_id` is an optional partition within `add_events_to_memory`.
117
+
118
+ ### How memory is consumed by agents
119
+
120
+ ADK ships two built-in memory tools:
121
+
122
+ - **`LoadMemoryTool`** — reactive. The model decides when to call `load_memory(query)`, which calls `tool_context.search_memory(query)`. Only available when `FeatureName.JSON_SCHEMA_FOR_FUNC_DECL` is enabled.
123
+ - **`PreloadMemoryTool`** — automatic. On every LLM request, searches memory using the user's query text and prepends a `<PAST_CONVERSATIONS>` system instruction block. No model decision needed.
124
+
125
+ Both are `tools` added to an agent's `tool_executor`, not part of the `BaseMemoryService` interface.
126
+
127
+ ### Memory is wired through `InvocationContext`
128
+
129
+ ```python
130
+ class InvocationContext(BaseModel):
131
+ # ...
132
+ memory_service: Optional[BaseMemoryService] = None
133
+ # ...
134
+ ```
135
+
136
+ And agents access it via `Context` (the tool/tool call context):
137
+
138
+ ```python
139
+ # In agent tools / callbacks:
140
+ ctx = Context(...) # = ToolContext
141
+ await ctx.search_memory("what did user ask yesterday?")
142
+ await ctx.add_session_to_memory()
143
+ await ctx.add_events_to_memory(events=[...])
144
+ await ctx.add_memory(memories=[MemoryEntry(...)])
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Q3: Persistence Contract — "Memory" vs "Knowledge" / "Session"
150
+
151
+ **Yes, ADK explicitly separates these concepts into two distinct abstractions:**
152
+
153
+ ### Session Service — `BaseSessionService`
154
+
155
+ Location: `google/adk/sessions/base_session_service.py`
156
+
157
+ Handles **conversation history** (ordered events within a session). Think "chat transcript".
158
+
159
+ ```python
160
+ class BaseSessionService(abc.ABC):
161
+ @abc.abstractmethod
162
+ async def create_session(self, *, app_name, user_id, state=None, session_id=None) -> Session
163
+ @abc.abstractmethod
164
+ async def get_session(self, *, app_name, user_id, session_id, config=None) -> Optional[Session]
165
+ @abc.abstractmethod
166
+ async def list_sessions(self, *, app_name, user_id=None) -> ListSessionsResponse
167
+ @abc.abstractmethod
168
+ async def delete_session(self, *, app_name, user_id, session_id) -> None
169
+
170
+ # non-abstract: append_event (adds events in-memory, updates session state)
171
+ ```
172
+
173
+ Shipped implementations:
174
+ - `InMemorySessionService` — volatile, for prototyping
175
+ - `SqliteSessionService` — file-based SQLite
176
+ - `DatabaseSessionService` — generic SQL (supports PostgreSQL, MySQL)
177
+ - `VertexAiSessionService` — stores sessions in Vertex AI
178
+
179
+ ### Memory Service — `BaseMemoryService`
180
+
181
+ Location: `google/adk/memory/base_memory_service.py`
182
+
183
+ Handles **searchable, cross-session knowledge** derived from conversation events. Think "vector store / RAG".
184
+
185
+ ```python
186
+ class BaseMemoryService(ABC):
187
+ @abstractmethod
188
+ async def add_session_to_memory(self, session: Session) -> None
189
+ @abstractmethod
190
+ async def search_memory(self, *, app_name, user_id, query) -> SearchMemoryResponse
191
+ # + optional add_events_to_memory, add_memory (see Q2)
192
+ ```
193
+
194
+ Shipped implementations:
195
+ - `InMemoryMemoryService` — keyword matching, prototyping only
196
+ - `VertexAiMemoryBankService` — Google Cloud's managed Memory Bank (vector + LLM-summarized)
197
+ - `VertexAiRagMemoryService` — Vertex AI RAG corpora
198
+
199
+ ### Retrieval tools — `BaseRetrievalTool`
200
+
201
+ A third concern: **external knowledge retrieval** (files, RAG corpora, databases).
202
+
203
+ Location: `google/adk/tools/retrieval/base_retrieval_tool.py`
204
+
205
+ This is a `Tool` (callable by the agent model), not a service. Shipped implementations:
206
+ - `FilesRetrieval` — local file-based retrieval
207
+ - `LlamaIndexRetrieval` — LlamaIndex-backed retrieval
208
+ - `VertexAiRagRetrieval` — Vertex AI RAG as a tool
209
+
210
+ **Summary:** ADK has a clean 3-way split:
211
+ 1. **Sessions** = ordered conversation history (persist per-session)
212
+ 2. **Memory** = searchable, cross-session knowledge (persist per-user)
213
+ 3. **Retrieval** = external document/knowledge access (tool-based)
214
+
215
+ ---
216
+
217
+ ## Q4: Reference Implementations — Shipped & Community
218
+
219
+ ### Shipped with ADK (in `google.adk.memory`)
220
+
221
+ | Implementation | Source | Description |
222
+ |---|---|---|
223
+ | `InMemoryMemoryService` | `google/adk/memory/in_memory_memory_service.py` | Keyword matching, thread-safe, dev/testing only |
224
+ | `VertexAiMemoryBankService` | `google/adk/memory/vertex_ai_memory_bank_service.py` | Google Cloud Memory Bank — uses `memories.ingest_events` + `memories.generate` + `memories.retrieve`. Supports TTL, revision tracking, metadata consolidation |
225
+ | `VertexAiRagMemoryService` | `google/adk/memory/vertex_ai_rag_memory_service.py` | Vertex AI RAG corpora — uploads session events as temp JSON files, retrieves via `rag.retrieval_query` |
226
+
227
+ ### Community packages
228
+
229
+ | Package | Source | Description |
230
+ |---|---|---|
231
+ | `google-adk-redis` (0.0.4) | https://github.com/redis-developer/adk-redis | Redis integration — implements Memory, Sessions, and Search tools |
232
+ | `google-adk-community` (0.4.1) | PyPI | Community extensions (specifics unknown without install) |
233
+
234
+ ### Shipped session backends (for reference)
235
+
236
+ | Implementation | Source |
237
+ |---|---|
238
+ | `InMemorySessionService` | `google/adk/sessions/in_memory_session_service.py` |
239
+ | `SqliteSessionService` | `google/adk/sessions/sqlite_session_session_service.py` |
240
+ | `DatabaseSessionService` | `google/adk/sessions/database_session_service.py` |
241
+ | `VertexAiSessionService` | `google/adk/sessions/vertex_ai_session_service.py` |
242
+
243
+ ---
244
+
245
+ ## Q5: Auth / Config Conventions
246
+
247
+ ### LLM configuration
248
+
249
+ ADK uses the `google-genai` SDK client for model calls. Config conventions:
250
+
251
+ ```python
252
+ # Standard Gemini (AI Studio API key):
253
+ from google.adk.models.google_llm import Gemini
254
+ model = Gemini(model="gemini-2.5-flash")
255
+ # Needs: GOOGLE_API_KEY env var
256
+
257
+ # Vertex AI (project + location):
258
+ model = Gemini(model="gemini-2.5-flash") # same constructor
259
+ # Needs: GOOGLE_GENAI_USE_VERTEXAI=true, GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_LOCATION env vars
260
+ # Or: google.auth default credentials (Application Default Credentials)
261
+ ```
262
+
263
+ Key env vars:
264
+ | Env var | Purpose |
265
+ |---|---|
266
+ | `GOOGLE_API_KEY` | AI Studio API key (Express Mode) |
267
+ | `GOOGLE_GENAI_USE_VERTEXAI` | If `true`/`1`, routes to Vertex AI instead of AI Studio |
268
+ | `GOOGLE_CLOUD_PROJECT` | GCP project ID for Vertex AI |
269
+ | `GOOGLE_CLOUD_LOCATION` | GCP region for Vertex AI |
270
+ | `APIGEE_PROXY_URL` | Apigee proxy URL (for `ApigeeLlm`) |
271
+
272
+ ### Memory service configuration
273
+
274
+ Services take explicit constructor params — no env var convention for memory backends:
275
+
276
+ ```python
277
+ # VertexAiMemoryBankService — requires agent engine ID
278
+ from google.adk.memory import VertexAiMemoryBankService
279
+ memory = VertexAiMemoryBankService(
280
+ project="my-project",
281
+ location="us-central1",
282
+ agent_engine_id="456",
283
+ )
284
+
285
+ # VertexAiRagMemoryService — requires RAG corpus
286
+ from google.adk.memory import VertexAiRagMemoryService
287
+ memory = VertexAiRagMemoryService(
288
+ rag_corpus="projects/.../ragCorpora/my-corpus",
289
+ similarity_top_k=5,
290
+ )
291
+ ```
292
+
293
+ ### Server wiring
294
+
295
+ In the ADK CLI web server (`adk web`), memory_service is injected at the server level:
296
+
297
+ ```python
298
+ ```python
299
+ # WARNING: FastApiAdkRunner class name requires verification — not located in adk-python @ v1.32.0.
300
+ # The adk-python main branch has `Runner` and `InMemoryRunner` in `google/adk/runners.py`,
301
+ # and `adk_web_server.py` handles the HTTP entry point without a FastAPI-specific runner class.
302
+ # The memory_service injection pattern (runner → InvocationContext → ctx) is correct,
303
+ # but the specific class name may be internal, renamed, or from a different ADK version.
304
+ # Verified against: https://github.com/google/adk-python (main branch, 2026-05-05)
305
+ ```
306
+
307
+ The runner pattern injects `memory_service` into each `InvocationContext`, making it available to all agents via `ctx.search_memory()`, `ctx.add_session_to_memory()`, etc.
308
+
309
+ ### Session-scoped config
310
+
311
+ There is **no per-session memory config**. Memory is configured at the app/server level (one `BaseMemoryService` instance per runner). The `(app_name, user_id)` tuple in each call acts as the scope.
312
+
313
+ ---
314
+
315
+ ## Adapter Design Notes for `flair-adk-py`
316
+
317
+ Based on this investigation, a `flair-adk-py` adapter would:
318
+
319
+ 1. **Subclass `BaseMemoryService`** — implement `add_session_to_memory()` and `search_memory()`. Optionally implement `add_events_to_memory()` and `add_memory()`.
320
+
321
+ 2. **Handle `Event` → Flair event conversion** — ADK events use `google.genai.types.Content` (parts-based). Need to map `Content.parts[i].text` → Flair events.
322
+
323
+ 3. **Handle Flair event → `MemoryEntry` conversion** — for `search_memory()`, return `SearchMemoryResponse(memories=[MemoryEntry(content=..., author=..., timestamp=...)])`.
324
+
325
+ 4. **Inject via `InvocationContext`** — the adapter instance is passed as `memory_service=MyFlairMemoryService()` when constructing the runner.
326
+
327
+ 5. **Scope mapping** — ADK scopes to `(app_name, user_id)`. Map `app_name` → Flair space, `user_id` → Flair user/agent.
328
+
329
+ 6. **Session vs Memory** — ADK already handles session persistence separately via `BaseSessionService`. The adapter only needs to handle the memory/search side.
330
+
331
+ No implementation was done — this is a research report.
@@ -0,0 +1,229 @@
1
+ # Headless agent-auth to MCP — the Flair/consumer half
2
+
3
+ > **Status:** complete against the published `@harperfast/oauth@2.2.0`, with
4
+ > one explicitly-drawn boundary: the full over-network CIMD fetch + token
5
+ > mint cannot be exercised end-to-end against a loopback Harper (the
6
+ > plugin's unconditional SSRF gate forbids it — by design, see
7
+ > [the SSRF/loopback boundary](#the-ssrfloopback-boundary) below). That last
8
+ > hop is deferred to a follow-up e2e against a real public HTTPS host;
9
+ > `bench.tps.harperfabric.com` is the planned venue.
10
+
11
+ This is the Flair-side consumer for RFC 7523 `client_credentials` +
12
+ `private_key_jwt` agent-auth: a headless Flair agent authenticating *as
13
+ itself* — no browser, no human — to a Harper MCP `/mcp` endpoint, using its
14
+ existing Ed25519 identity key. Plugin side:
15
+ [HarperFast/oauth#159](https://github.com/HarperFast/oauth/issues/159)
16
+ (parent issue, decomposed into 4 parts — all shipped, see below).
17
+
18
+ ## Upstream state (as of 2026-07-12)
19
+
20
+ | # | What | State |
21
+ |---|------|-------|
22
+ | #160 / PR #165 | client-assertion primitives (strict EdDSA verify + `jti` replay store) | **SHIPPED** (merged @ `d48c3b2`) |
23
+ | #161 / #167 | CIMD-first client resolution & validation for `private_key_jwt` agents (SSRF-guarded fetch, validation, cache) | **SHIPPED** in 2.2.0 via PR #170 |
24
+ | #162 / PR #170 | token-endpoint grant — assertion + resolved client + RFC 8707 resource binding | **SHIPPED** in 2.2.0 |
25
+ | #163 / PR #171 | token-issuance rate limiting (per verified client_id, post-auth debit) | **SHIPPED** in 2.2.0 |
26
+
27
+ Every claim below about plugin behavior is read from the **published 2.2.0
28
+ package source** (`node_modules/@harperfast/oauth/dist/lib/mcp/*.js`) and,
29
+ where testable, proven against that real code — not mirrored, not guessed.
30
+ (History note: a 2026-07-09 revision of this doc corrected an earlier false
31
+ "#167 merged" claim; #167 has since genuinely shipped as part of 2.2.0.)
32
+
33
+ ## 1. Assertion signing + live token mint — `flair mcp token`
34
+
35
+ `src/mcp-client-assertion.ts` builds + signs the `client_assertion` JWT:
36
+ header `{alg: "EdDSA", typ: "JWT"}`; claims `iss = sub = client_id`, `aud =
37
+ token endpoint`, `exp - iat ≤ 60s` (hard-capped, not just defaulted), `iat`,
38
+ random `jti`. Signed with `node:crypto` alone (no new dependency, matching
39
+ the plugin's own approach and this repo's existing `flair-client.mjs` /
40
+ `buildEd25519Auth` signing style). The claim shape is pinned to what PR #165
41
+ (`src/lib/mcp/clientAssertion.ts`) verifies, and
42
+ `test/unit/mcp-client-credentials-live-package.test.ts` proves assertions
43
+ this module signs pass the plugin's **real** `verifyClientAssertion` —
44
+ including negative cases (wrong signing key, tampered payload).
45
+
46
+ Key loading (`resolveAgentKeyPath` / `loadEd25519PrivateKeyFromFile`) mirrors
47
+ `src/cli.ts`'s existing `resolveKeyPath` / `buildEd25519Auth` (used by the
48
+ TPS-Ed25519 REST-auth path) exactly: same search order (`--keys-dir` /
49
+ `FLAIR_KEY_DIR` / `~/.flair/keys` / `~/.tps/secrets/flair`), same format
50
+ cascade (raw 32-byte seed, base64 seed, base64 PKCS8 DER, PEM fallback). No
51
+ new key material, no new on-disk format.
52
+
53
+ ### The live round-trip (formerly stubbed — now real)
54
+
55
+ `requestMcpAccessToken` POSTs the `client_credentials` grant to the token
56
+ endpoint (`/oauth/mcp/token`) and returns the minted token. Its error
57
+ behavior is deliberate:
58
+
59
+ - **429 `slow_down`** (the #171/#163 issuance rate limit): honors the
60
+ `Retry-After` header with **full jitter** (sleep a random duration in
61
+ `[0, Retry-After]`), retrying up to `maxRetries` (default 3 — an
62
+ assertion is only valid ≤ 60s, so unbounded retry would outlive it).
63
+ A missing `Retry-After` falls back to capped exponential backoff — never
64
+ hammer, even a non-conformant server.
65
+ - **Any other non-2xx** throws `McpTokenRequestError` (status + OAuth error
66
+ code preserved) with **no retry** — those responses mean the request
67
+ itself is wrong, and retrying identically would just burn a fresh `jti`.
68
+
69
+ `getMcpAccessToken` wraps it with the consumer-side requirements the 2.2.0
70
+ rate limiter creates (pinned in flair#663's tracking thread): **mint
71
+ sparingly** — tokens are cached per `(clientId, tokenEndpoint, resource)`
72
+ and reused until within a refresh margin (default 30s) of expiry, because
73
+ the limiter debits per *mint*, not per use. `forceRefresh` bypasses the
74
+ cache (e.g. after an unexpected 401).
75
+
76
+ CLI: `flair mcp token --agent-id <id>` now performs the real mint by
77
+ default; `--dry-run` restores the old sign-and-print behavior.
78
+ `--client-id`/`--token-endpoint`/`--resource` default to this instance's own
79
+ oauth surface (derived from `FLAIR_MCP_ISSUER` / `FLAIR_PUBLIC_URL`,
80
+ matching `resources/mcp-oauth-flag.ts`) but are fully overridable — an
81
+ agent can authenticate to a **different** Harper MCP server; identity is
82
+ portable.
83
+
84
+ ### RFC 8707 `resource`
85
+
86
+ `buildTokenRequestForm` carries an optional `resource` field, pass-through
87
+ only (it never invents a value). The CLI supplies the default:
88
+ `${FLAIR_MCP_ISSUER}/mcp`, the same canonical resource identifier
89
+ `resources/mcp-oauth-flag.ts`'s `mcpResource()` uses for this instance's own
90
+ `/mcp` audience binding — exact-match, fail-closed on the AS side. The
91
+ `resource` is part of the token-cache key: a token minted for one resource
92
+ never serves another.
93
+
94
+ ## 2. CIMD publish — `resources/MCPClientMetadata.ts`
95
+
96
+ Each agent's Client ID Metadata Document is **served**, not just generated:
97
+ `GET /MCPClientMetadata/{agentId}` (public, unauthenticated — mirrors
98
+ `AgentCard.ts`'s A2A-card posture). `client_id` = that URL itself (derived
99
+ from `mcpIssuer()`, same env vars as above); `jwks` = the agent's existing
100
+ `Agent.publicKey` re-expressed as a JWK OKP.
101
+
102
+ Why served rather than exported for external hosting: Flair already
103
+ publishes public agent-discovery metadata this way (`AgentCard.ts`), CIMD is
104
+ explicitly the **stateless** registration path (no DCR row to replicate
105
+ across Fabric nodes), and the agent's public key is already Flair's source
106
+ of truth. Serving it keeps that single source of truth instead of
107
+ introducing a second, externally-hosted copy that could drift.
108
+
109
+ Field logic lives in `resources/mcp-client-metadata-fields.ts` (Harper-free,
110
+ mirrors `agentcard-fields.ts`'s pattern), shape-pinned to oauth#161:
111
+
112
+ - `grant_types: ["client_credentials"]`; `token_endpoint_auth_method:
113
+ "private_key_jwt"`.
114
+ - `jwks` = a JWK Set containing exactly one PUBLIC OKP/Ed25519 key.
115
+ `buildCimdDocument` rejects non-OKP/non-Ed25519 keys, a missing/malformed
116
+ `x`, and any JWK carrying a private `d` component (defensive runtime
117
+ check; the TS type has no `d` field).
118
+ - `redirect_uris` and `response_types` are BOTH omitted — neither exists on
119
+ the `CimdDocument` type, so neither can leak back in. This is the
120
+ conditional shape #161 specifies for client_credentials-only clients.
121
+
122
+ This document is proven against the plugin's **real published pipeline**:
123
+ `test/unit/mcp-client-credentials-live-package.test.ts` drives 2.2.0's
124
+ actual `resolveCimdClient` (fetch → SSRF gate → validate → cache) via its
125
+ exported `_setDnsLookup`/`_setFetch` test hooks and confirms the document
126
+ resolves end-to-end, plus the fail-closed negatives: no
127
+ `clientIdMetadataDocuments.allowedHosts` configured → rejected even though
128
+ the fetch succeeded, and a document with leaked private-key material →
129
+ rejected by the plugin's validator even if our own build-time guard were
130
+ bypassed.
131
+
132
+ ## 3. Test evidence — three layers, strongest available at each boundary
133
+
134
+ 1. **Mirror unit tests** (`test/unit/mcp-client-assertion.test.ts`) — our
135
+ signer vs. a local mirror of #165's verification contract, all negative
136
+ cases; plus the full `requestMcpAccessToken`/`getMcpAccessToken` behavior
137
+ matrix (429 backoff with and without `Retry-After`, retry exhaustion,
138
+ non-JSON responses, cache reuse/expiry/`forceRefresh`/per-resource
139
+ isolation) against an injected fetch.
140
+ 2. **Live-package unit tests**
141
+ (`test/unit/mcp-client-credentials-live-package.test.ts`) — no mirrors:
142
+ the REAL published 2.2.0 `verifyClientAssertion`, `resolveCimdClient`,
143
+ and `createRateLimiter` composed in-process. Includes the
144
+ post-auth-debit ordering proof (#171/#163): a forged assertion never
145
+ reaches the rate limiter, so it cannot drain a real client's bucket; and
146
+ the exact-`Retry-After` handshake between the plugin's limiter and our
147
+ client backoff.
148
+ 3. **Live-Harper e2e**
149
+ (`test/integration/mcp-client-credentials-e2e.test.ts`) — an ephemeral
150
+ Harper (via `test/helpers/harper-lifecycle.ts`) running
151
+ `@harperfast/oauth@2.2.0` as a real mounted component. Proves, over real
152
+ HTTP: the AS discovery document advertises
153
+ `client_credentials`/`private_key_jwt`/`EdDSA`; our CIMD document is
154
+ served correctly; a real grant naming our agent reaches CIMD resolution
155
+ and is rejected **only** by the SSRF/DNS gate (distinguishable from both
156
+ shape errors and the allowlist gate, which is proven live and distinct);
157
+ and our client helper surfaces the rejection as a typed error.
158
+
159
+ ## The SSRF/loopback boundary
160
+
161
+ Read this before trying to "finish" the e2e locally — the wall is real and
162
+ it is the plugin working as designed, not a bug:
163
+
164
+ `@harperfast/oauth@2.2.0`'s CIMD document fetch (`dist/lib/mcp/cimd.js`)
165
+ enforces an **unconditional SSRF gate**: the `client_id` URL must be
166
+ `https://` (no loopback carve-out — contrast `mcp.issuer`'s explicit
167
+ loopback TLS exception, which has no analog here), and every DNS-resolved
168
+ address is checked against the full IANA private/loopback/link-local ranges
169
+ with **no override or allowlist knob**. A CIMD document served by a
170
+ loopback/private-network Harper can therefore never be fetched by the AS
171
+ over a real network hop. Consequences, all verified empirically while
172
+ building this slice:
173
+
174
+ - The plugin's `_setDnsLookup`/`_setFetch` injection hooks only help when
175
+ the test and the plugin run in the **same process**. A spawned Harper
176
+ (the `harper-lifecycle.ts` pattern) does not share the test's module
177
+ registry.
178
+ - Harper's component loader gives each `package:`-declared component an
179
+ **isolated module graph even within the same process** — a same-process
180
+ JS resource that deep-imports `cimd.js` and arms the hooks on its own
181
+ import instance does not affect the module instance the plugin's
182
+ `/oauth/mcp/token` route uses (confirmed by direct experiment: the
183
+ harness's own `resolveClient` call succeeded against the mocked
184
+ transport; the live HTTP endpoint, hit immediately after, still failed
185
+ with the identical SSRF rejection).
186
+ - Seeding the DCR client store is not a bypass either:
187
+ `MCPClientStore`'s `encodeRecord`/`decodeRecord`
188
+ (`dist/lib/mcp/clientStore.js`) persist no `jwks`/`_cimd` fields, so a
189
+ stored client can never satisfy `handleClientCredentialsGrant`'s
190
+ `client._cimd !== true` gate — confirmed by reading the published source.
191
+
192
+ So the local e2e drives the live grant as far as it can physically go —
193
+ into CIMD resolution, stopped only by the DNS gate — and the remaining
194
+ behavior (the plugin's real fetch of our served document, followed by a
195
+ 200 token mint over the network) is covered in-process by the live-package
196
+ tests instead. **The genuine end-to-end over-network mint is deferred** to
197
+ an environment where this Flair instance's CIMD route is served from a
198
+ real, publicly-resolvable HTTPS host. Planned follow-up: run exactly
199
+ `test/integration/mcp-client-credentials-e2e.test.ts`'s happy path against
200
+ `bench.tps.harperfabric.com` (our public bench host) with
201
+ `clientIdMetadataDocuments.allowedHosts` pointed at it — at which point the
202
+ SSRF-gate rejection assertion flips into a 200-mint assertion. Until that
203
+ runs, "token mints over a real network hop" remains **inferred** from the
204
+ in-process proof, not measured; everything else above is measured.
205
+
206
+ ## Deployment coordination note
207
+
208
+ Whoever owns the AS-side `@harperfast/oauth` config for a given Harper
209
+ instance MUST add this Flair instance's `MCPClientMetadata` host to
210
+ `clientIdMetadataDocuments.allowedHosts`. That host is derived from
211
+ `FLAIR_MCP_ISSUER` (or `FLAIR_PUBLIC_URL` as fallback) — the same env var
212
+ `mcpIssuer()` in `resources/mcp-oauth-flag.ts` and `defaultMcpClientId()` in
213
+ `src/mcp-client-assertion.ts` both read. If the allowlist isn't updated,
214
+ `/mcp` client_credentials auth for this agent fails closed (by design; the
215
+ e2e proves the "Unknown client" rejection live). This is an operational
216
+ step, not a code change; flag it explicitly when coordinating a rollout.
217
+ The AS must also be able to fetch that host over public HTTPS — see the
218
+ SSRF boundary above.
219
+
220
+ ## What's NOT in this slice
221
+
222
+ - The over-network CIMD fetch + 200 token mint e2e — deferred to the
223
+ public-host follow-up described above.
224
+ - `flair agent register-mcp-client` — not needed for the CIMD path (#161
225
+ replaced the DCR-shaped registration gate with the AS-side host
226
+ allowlist); only worth revisiting if DCR back-compat ever matters.
227
+ - Wiring the minted access token into an actual MCP client session
228
+ (`Authorization: Bearer` against `/mcp`) — the token is minted and
229
+ cached; consuming it is the natural next slice.