Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,23 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
claimed a "background cleanup worker" that did not exist; it now describes what
runs.

- **Optional API-key authentication** (`amp_server.auth` +
`AMP_API_KEYS_FILE`). The spec carries agent identity in `X-AMP-Agent-ID` and
defines no credential (RFC §6.1), so the header was an assertion: anyone who
could reach the port could claim any agent id, and every access rule was decided
from that claim. With a key store configured, the id must now be proven with a
matching key in `X-AMP-API-Key`; the file stores `sha256:` digests rather than
keys, and a wrong key, a missing key and an unknown agent id all answer the same
`401 UNAUTHENTICATED` so the endpoints cannot enumerate agent ids. Off by
default, so the spec's binding keeps working unchanged for anyone who has not
opted in; a key store that cannot be read stops the server instead of silently
falling back to trusting the header. This also closes `POST /memories`'s
`identity.created_by` fallback whenever keys are configured - without that, the
fallback would have been an authentication bypass. Both SDKs take the key as a
constructor argument, and identity resolution now happens in one FastAPI
dependency rather than in each handler, so a route cannot resolve an agent
without proving it.

### Changed
- **Every endpoint returns one error shape.** `PATCH /memories/{id}` answered a
conflict with `{"detail": ...}` while `DELETE` answered with
Expand Down
53 changes: 52 additions & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
**Protocol version:** `0.1.0`
**Content-Type:** `application/json`

All endpoints accept and return JSON. Memory-cell access control is expressed via `access_policy` on each cell; the only endpoint with its own auth is `POST /lifecycle/run`, gated on an admin token.
All endpoints accept and return JSON. Memory-cell access control is expressed via `access_policy` on each cell. Identity travels in the `X-AMP-Agent-ID` header; see [Authentication](#authentication) for how that is proven when the server is run with API keys, and `POST /lifecycle/run` for the one endpoint with a separate admin token.

---

Expand All @@ -25,6 +25,55 @@ All endpoints accept and return JSON. Memory-cell access control is expressed vi

---

## Authentication

Three separate things, which are easy to confuse:

**Agent identity.** `X-AMP-Agent-ID` names the agent making the request. The spec
defines identity here and defines no credential
([RFC-AMP-001 §6.1](https://github.com/glatinone/agent-memory-protocol/blob/master/spec/rfcs/RFC-AMP-001.md)),
so by default the header is taken at its word - every access rule downstream
(`readable_by`, `writable_by`, the owner check) is decided from it. A server run
this way is exactly as the spec describes, and is only safe on a network you
control.

**API keys (optional).** Set `AMP_API_KEYS_FILE` to a JSON file mapping agent id
to a key digest, and the header must then be proven:

```json
{
"agent_assistant": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
}
```

```bash
python -m amp_server.auth hash 'the-agent-key' # prints the value to paste
export AMP_API_KEYS_FILE=/etc/amp/api-keys.json
```

The file holds digests, never keys, so a leaked file does not hand over working
credentials. With keys configured, a request must send the matching key in
`X-AMP-API-Key`; a missing key, a wrong key and a key for an agent that does not
exist all answer the same `401 UNAUTHENTICATED`, so the endpoints cannot be used
to enumerate agent ids. Two consequences worth stating plainly:

- `POST /memories` no longer falls back to `identity.created_by` in the body.
That fallback is the documented default without keys, and with keys configured
it would let any caller create a cell as any agent.
- A store that cannot be read stops the server from starting. Falling back to
trusting the header would silently remove the protection an operator asked for.

`GET /health` and `GET /spec` stay open: clients and probes read them before they
have a key. The SDKs accept the key as a constructor argument
(`AMPClient(url, agent_id, api_key=...)`, `new AMPClient(url, agentId, apiKey)`).

**Admin token.** `POST /lifecycle/run` is gated on `AMP_ADMIN_TOKEN`, separately
from the above, because it mutates lifecycle state for every cell in storage and
erases data outright when `AMP_PURGE_RETENTION` is on. With no token configured
the endpoint answers `403 ADMIN_DISABLED` rather than being open.

---

## Machine-readable contract

The table above is prose. The contract itself is
Expand Down Expand Up @@ -86,6 +135,7 @@ curl http://localhost:8765/amp/v1/spec
"capabilities": {
"mcp_compatible": false,
"storage_backends": ["chroma"],
"api_keys_required": false,
"embedding": {"provider": "chroma-default", "dimensions": 384},
"max_cell_size_bytes": 65536,
"retention_days": 30,
Expand All @@ -108,6 +158,7 @@ suite checks it against the server's own numbers rather than a fixed value:
| Capability | What it commits the server to |
|---|---|
| `mcp_compatible` | Whether **this HTTP server** speaks MCP directly. It is `false`: the MCP integration ships as a separate stdio process (`amp-mcp`, see `examples/mcp-claude-desktop/`), not as an endpoint on this API. |
| `api_keys_required` | Whether this server requires `X-AMP-API-Key` (`AMP_API_KEYS_FILE` is set). Reported here so a client learns it needs a key before a call fails with `401`. |
| `storage_backends` | The adapter actually wired in (`chroma` or `postgres`), selected with `AMP_STORAGE_BACKEND`; see [getting started](getting-started.md#6-choosing-a-storage-backend). |
| `embedding` | Which provider turns text into vectors, and the width of the vectors it produces (`null` when the service decides per request). Configured with `AMP_EMBEDDING_PROVIDER`; see [getting started](getting-started.md#5-choosing-an-embedding-provider). |
| `max_cell_size_bytes` | The largest serialized cell the server will accept. Enforced on create and on update; a larger cell is refused with `413 CELL_TOO_LARGE` before anything is written, and the number here is the number the check uses. |
Expand Down
34 changes: 34 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,37 @@ order. `GET /spec` reports which one is in use under `storage_backends`.

The embedding-provider rules in step 5 apply to either backend: switching
provider invalidates the vectors already stored.

---

## 7. Turning on API keys (optional)

By default the server trusts the `X-AMP-Agent-ID` header, which is what the spec's
binding describes: the header names the agent, and every access rule is decided
from it. Anyone who can reach the port can therefore claim any agent id.

To require proof, point the server at a key store:

```bash
python -m amp_server.auth hash 'the-agent-key' # prints the value to paste
```

```json title="api-keys.json"
{
"agent_assistant": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"
}
```

```bash
export AMP_API_KEYS_FILE=/etc/amp/api-keys.json
```

Clients then send the key alongside the identity header:

```python
client = AMPClient("http://localhost:8765", "agent_assistant", api_key="the-agent-key")
```

The file stores digests rather than keys, and a store that cannot be read stops
the server rather than falling back to trusting the header. Full detail, including
the two rules this changes, is in the [API reference](api-reference.md#authentication).
4 changes: 4 additions & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ Store and retrieve memories with the synchronous client in under 5 lines:
from amp_client import AMPClient

client = AMPClient("http://localhost:8765", agent_id="agent_assistant")

# Only when the server runs with AMP_API_KEYS_FILE; without it the agent id is
# accepted on its own, which is the binding the spec describes.
client = AMPClient("http://localhost:8765", agent_id="agent_assistant", api_key="...")
client.remember(content="User prefers email correspondence.", owner_id="user_123")
memories = client.recall(query="communication preferences", owner_id="user_123")
print(memories[0]["content"]["text"])
Expand Down
23 changes: 18 additions & 5 deletions sdk/amp_client/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,26 @@
class AsyncAMPClient:
"""Asynchronous AMP Client using httpx."""

def __init__(self, server_url: str, agent_id: str):
def __init__(self, server_url: str, agent_id: str, api_key: str | None = None):
"""Initialize the async AMP client.

`api_key` is the key belonging to `agent_id`, sent as `X-AMP-API-Key`. It
is only needed when the server was started with `AMP_API_KEYS_FILE`.
"""
self.server_url = server_url.rstrip("/")
if not self.server_url.endswith("/amp/v1"):
self.server_url += "/amp/v1"
self.agent_id = agent_id
self.api_key = api_key
self._client: httpx.AsyncClient | None = None

def identity_headers(self) -> dict[str, str]:
"""See AMPClient.identity_headers: one place, so no call path is missed."""
headers = {"X-AMP-Agent-ID": self.agent_id}
if self.api_key:
headers["X-AMP-API-Key"] = self.api_key
return headers

async def __aenter__(self) -> AsyncAMPClient:
self._client = httpx.AsyncClient()
return self
Expand Down Expand Up @@ -81,7 +94,7 @@ async def remember(
resp = await client.post(
f"{self.server_url}/memories",
json=body,
headers={"X-AMP-Agent-ID": self.agent_id},
headers=self.identity_headers(),
)
except httpx.HTTPError as exc:
raise AMPError(f"HTTP request failed: {exc}") from exc
Expand All @@ -108,7 +121,7 @@ async def recall(
resp = await client.post(
f"{self.server_url}/memories/search",
json=body,
headers={"X-AMP-Agent-ID": self.agent_id},
headers=self.identity_headers(),
)
except httpx.HTTPError as exc:
raise AMPError(f"HTTP request failed: {exc}") from exc
Expand All @@ -122,7 +135,7 @@ async def forget(self, memory_id: str) -> bool:
try:
resp = await client.delete(
f"{self.server_url}/memories/{memory_id}",
headers={"X-AMP-Agent-ID": self.agent_id},
headers=self.identity_headers(),
)
except httpx.HTTPError as exc:
raise AMPError(f"HTTP request failed: {exc}") from exc
Expand All @@ -149,7 +162,7 @@ async def list_memories(
resp = await client.get(
f"{self.server_url}/memories",
params=params,
headers={"X-AMP-Agent-ID": self.agent_id},
headers=self.identity_headers(),
)
except httpx.HTTPError as exc:
raise AMPError(f"HTTP request failed: {exc}") from exc
Expand Down
27 changes: 22 additions & 5 deletions sdk/amp_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,18 @@
class AMPClient:
"""Synchronous client for the Agent Memory Protocol (AMP) server."""

def __init__(self, server_url: str, agent_id: str) -> None:
def __init__(
self, server_url: str, agent_id: str, api_key: str | None = None
) -> None:
"""Initialize the AMP client.

Args:
server_url: The base URL of the AMP server.
agent_id: The ID of the agent using the client.
api_key: The key belonging to `agent_id`, sent as `X-AMP-API-Key`.
Only needed when the server was started with
`AMP_API_KEYS_FILE`; without that, the server accepts the agent
id on its own, which is the binding the spec describes.
"""
# Normalize server_url (strip trailing slash and append /amp/v1 if not present)
normalized_url = server_url.rstrip("/")
Expand All @@ -24,16 +30,27 @@ def __init__(self, server_url: str, agent_id: str) -> None:

self.server_url = normalized_url
self.agent_id = agent_id
self.api_key = api_key
self.session = requests.Session()

def identity_headers(self) -> dict[str, str]:
"""The headers that identify this client, built in one place.

One source, for the same reason the server resolves identity in one
dependency: a credential added to one call path and forgotten on another
fails as a confusing 401 rather than as a code error.
"""
headers = {"X-AMP-Agent-ID": self.agent_id}
if self.api_key:
headers["X-AMP-API-Key"] = self.api_key
return headers

def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
"""Internal helper to execute HTTP requests with error handling."""
url = f"{self.server_url}{path}"

# Ensure standard headers are present
headers = kwargs.pop("headers", {})
if "X-AMP-Agent-ID" not in headers:
headers["X-AMP-Agent-ID"] = self.agent_id
# Identity first, so a caller-supplied header can still override it.
headers = {**self.identity_headers(), **kwargs.pop("headers", {})}

try:
response = self.session.request(method, url, headers=headers, **kwargs)
Expand Down
5 changes: 4 additions & 1 deletion sdk/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@ All methods are async. `serverUrl` is normalized the same way as the Python
client: a bare host, a host with a trailing slash, or an explicit `/amp/v1` all
resolve to the same endpoint prefix.

### `new AMPClient(serverUrl, agentId)`
### `new AMPClient(serverUrl, agentId, apiKey?)`

- `serverUrl` - base URL of the AMP server.
- `agentId` - this agent's identifier, sent as the `X-AMP-Agent-ID` header.
- `apiKey` - optional; this agent's key, sent as `X-AMP-API-Key`. Only needed when
the server is run with `AMP_API_KEYS_FILE`, in which case a request without it is
answered `401 UNAUTHENTICATED`.

### `remember(content, ownerId, options?)`

Expand Down
21 changes: 19 additions & 2 deletions sdk/node/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,11 @@ export class AMPClient {
/**
* @param {string} serverUrl Base URL of the AMP server.
* @param {string} agentId Identifier of this agent, sent as `X-AMP-Agent-ID`.
* @param {string} [apiKey] Key belonging to `agentId`, sent as
* `X-AMP-API-Key`. Only needed when the server is run with
* `AMP_API_KEYS_FILE`; otherwise the agent id alone is accepted.
*/
constructor(serverUrl, agentId) {
constructor(serverUrl, agentId, apiKey) {
if (!serverUrl) throw new AMPError("serverUrl is required");
if (!agentId) throw new AMPError("agentId is required");

Expand All @@ -50,6 +53,20 @@ export class AMPClient {
? normalized
: `${normalized}/amp/v1`;
this.agentId = agentId;
this.apiKey = apiKey;
}

/**
* The headers that identify this client, built in one place so no call path
* can be missing the credential.
*
* @returns {Record<string, string>}
* @private
*/
_identityHeaders() {
const headers = { "X-AMP-Agent-ID": this.agentId };
if (this.apiKey) headers["X-AMP-API-Key"] = this.apiKey;
return headers;
}

/**
Expand All @@ -66,7 +83,7 @@ export class AMPClient {
async _request(method, path, { body, headers = {} } = {}) {
const url = new URL(`${this.serverUrl}${path}`);

const finalHeaders = { "X-AMP-Agent-ID": this.agentId, ...headers };
const finalHeaders = { ...this._identityHeaders(), ...headers };
let payload;
if (body !== undefined) {
finalHeaders["Content-Type"] = "application/json";
Expand Down
20 changes: 20 additions & 0 deletions sdk/node/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ const OWNER = `node-sdk-test-user-${Date.now()}`;
// being collected, before any hook runs, and would always skip.
let serverUp = false;

describe("API keys", () => {
test("omits the key header unless one was given", () => {
const client = new AMPClient("http://localhost:8000", "agent-1");
assert.deepEqual(client._identityHeaders(), { "X-AMP-Agent-ID": "agent-1" });
});

test("carries the key header when one was given", () => {
const client = new AMPClient("http://localhost:8000", "agent-1", "agent-one-key");
assert.deepEqual(client._identityHeaders(), {
"X-AMP-Agent-ID": "agent-1",
"X-AMP-API-Key": "agent-one-key",
});
});

test("lets a caller-supplied header win", () => {
const client = new AMPClient("http://localhost:8000", "agent-1");
assert.deepEqual(client._identityHeaders(), { "X-AMP-Agent-ID": "agent-1" });
});
});

describe("URL normalization", () => {
test("appends /amp/v1 when missing", () => {
const client = new AMPClient("http://localhost:8000", "a");
Expand Down
19 changes: 19 additions & 0 deletions sdk/python/tests/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,22 @@ async def test_async_health_ok(mock_get):

async with AsyncAMPClient("http://localhost:8000", "test_agent") as client:
assert await client.health() is True


# ---------------------------------------------------------------------------
# API keys (X-AMP-API-Key)
# ---------------------------------------------------------------------------


def test_async_identity_headers_omit_the_key_unless_one_was_given():
client = AsyncAMPClient("http://localhost:8000", "agent-1")
assert client.identity_headers() == {"X-AMP-Agent-ID": "agent-1"}


def test_async_identity_headers_carry_the_key_when_one_was_given():
"""Every async call site uses this builder, so one test covers them all."""
client = AsyncAMPClient("http://localhost:8000", "agent-1", api_key="agent-one-key")
assert client.identity_headers() == {
"X-AMP-Agent-ID": "agent-1",
"X-AMP-API-Key": "agent-one-key",
}
Loading
Loading