@tpsdev-ai/flair 0.26.0 → 0.27.1
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/README.md +2 -0
- package/dist/cli.js +434 -50
- package/dist/doctor-client.js +47 -0
- package/dist/resources/migrations/embedding-stamp.js +83 -2
- package/dist/resources/migrations/runner.js +44 -0
- package/docs/assets/flair-cross-orchestrator.cast +33 -0
- package/docs/assets/flair-cross-orchestrator.gif +0 -0
- package/docs/assets/flair-demo.cast +18 -0
- package/docs/assets/flair-demo.gif +0 -0
- package/docs/auth.md +178 -0
- package/docs/bridges.md +291 -0
- package/docs/claude-code.md +202 -0
- package/docs/deployment.md +204 -0
- package/docs/entity-vocabulary.md +109 -0
- package/docs/federation.md +179 -0
- package/docs/integrations.md +197 -0
- package/docs/mcp-clients.md +240 -0
- package/docs/n8n-management.md +142 -0
- package/docs/n8n.md +122 -0
- package/docs/notes/adk-spike-findings-2026-05-05.md +331 -0
- package/docs/notes/mcp-agent-auth-consumer.md +229 -0
- package/docs/notes/mcp-oauth-model2.md +132 -0
- package/docs/notes/rem-ux.md +39 -0
- package/docs/openclaw.md +131 -0
- package/docs/quickstart.md +153 -0
- package/docs/releasing.md +173 -0
- package/docs/rem.md +60 -0
- package/docs/rerank-provisioning.md +67 -0
- package/docs/secrets-and-keys.md +173 -0
- package/docs/spoke-bringup.md +303 -0
- package/docs/supply-chain-policy.md +156 -0
- package/docs/system-requirements.md +42 -0
- package/docs/the-team.md +129 -0
- package/docs/troubleshooting.md +187 -0
- package/docs/upgrade.md +416 -0
- package/package.json +2 -1
|
@@ -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.
|