Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d1fcbb6
feat(external-context): Support open-source Mem0 providers
yiliang114 Aug 24, 2026
b4c1839
test(external-context): Cover the open-source Mem0 example config
yiliang114 Aug 24, 2026
ee3fefb
refactor(external-context): Address review on the mem0 provider
yiliang114 Aug 24, 2026
04aaef5
fix(external-context): Ship the mem0 example with HTTPS defaults
yiliang114 Aug 24, 2026
ec2707f
test(external-context): prove shipped mem0 example constructs a provider
yiliang114 Aug 25, 2026
d729081
refactor(external-context): address mem0 review round — merge duplica…
yiliang114 Aug 25, 2026
5702f63
fix(external-context): Scope OSS mem0 by agent_id instead of app_id
yiliang114 Aug 25, 2026
a381a6f
fix(external-context): Scope Mem0 provider to PolarDB
yiliang114 Aug 25, 2026
184071d
feat(external-context): add versioned Mem0 presets
yiliang114 Aug 27, 2026
c02db5b
fix(external-context): survive an unverified Mem0 deployment build
yiliang114 Aug 27, 2026
b1ded13
feat(external-context): add a read-only Mem0 preset probe
yiliang114 Aug 27, 2026
52892f7
refactor(external-context): drop the vendor prefix from the PolarDB p…
yiliang114 Aug 27, 2026
657c3f5
fix(external-context): restore bounded Mem0 contracts
yiliang114 Aug 27, 2026
eb21ca1
docs(external-context): ship a PolarDB example that loads as it is sh…
yiliang114 Aug 27, 2026
68cd9a0
docs(external-context): clarify PolarDB setup
yiliang114 Aug 27, 2026
1ebda66
Merge branch 'main' into feat/external-context-mem0-provider
yiliang114 Aug 30, 2026
de5531a
Merge branch 'main' into feat/external-context-mem0-provider
yiliang114 Sep 1, 2026
50c7b2d
Merge branch 'main' into feat/external-context-mem0-provider
yiliang114 Sep 2, 2026
155b4c0
Merge branch 'main' into feat/external-context-mem0-provider
yiliang114 Sep 2, 2026
9ea2b62
Merge branch 'main' into feat/external-context-mem0-provider
yiliang114 Sep 3, 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
43 changes: 38 additions & 5 deletions integrations/external-context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ three managed deployment variants:
- **Auto-recall:** version 2 configuration and an administrator-installed
`UserPromptSubmit` Hook, with no external-context MCP server.

The built-in adapters support Mem0 Platform V3 search and a small Generic HTTP
Search V1 contract for existing knowledge or RAG services. Only Mem0 has an
optional write path. There is no generic ingestion protocol, personal memory,
The built-in adapters support Mem0 Platform V3 search, the open-source Mem0
REST protocol (self-hosted servers and managed services such as Aliyun
PolarDB Mem0), and a small Generic HTTP Search V1 contract for existing
knowledge or RAG services. Only the Mem0 providers have an optional write
path. There is no generic ingestion protocol, personal memory,
trusted user identity, per-document ACL, or tamper-resistant audit.

Provider teams that need a separately owned and released integration should
Expand Down Expand Up @@ -377,14 +379,45 @@ it disabled when search must have no semantic provider-side state change.
Provider audit or access logs may still be retained. See
[Mem0 Memory Decay](https://docs.mem0.ai/platform/features/memory-decay).

## Mem0 (open-source protocol)
Comment thread
yiliang114 marked this conversation as resolved.
Outdated

The `mem0` provider type targets services that expose the open-source Mem0
REST API, such as a self-hosted mem0 server or Aliyun PolarDB Mem0. Search
calls `POST {baseUrl}/v2/memories/search` with the configured `user_id` (and
optional `app_id`) under `filters`; writes call `POST {baseUrl}/v1/memories`
with one exact user message and `infer: false`. Both paths are fixed by the
open-source Mem0 protocol; the `baseUrl` (scheme, host, and port only) is the
operator's switch for choosing the vendor — moving from one managed mem0
service to another is a config change, not a code change.

These services commonly expose plain HTTP on an IP whitelist. Because the
credential travels in cleartext on HTTP, keep `allowInsecureHttp` off unless
the whitelist is the intended network control, and prefer HTTPS or a loopback
relay (for example `ssh -L` or a VPC-side proxy) when one is available. The
shipped example therefore defaults to HTTPS and omits the flag: an HTTP
Comment thread
yiliang114 marked this conversation as resolved.
Outdated
`baseUrl` fails validation until `"allowInsecureHttp": true` is added
explicitly.

`userId` selects the tenant whose memories are read and written. It is fixed
in the configuration and is never supplied by the model. See
`examples/mem0-oss.json` for a complete configuration. That example is
read-only as shipped: `context_remember` is registered only when the v1
configuration carries `"write": { "enabled": true }`, and enabling it should
reuse the managed write-confirmation Hook and permissions from the Mem0 write
variant above.

## Rollout and rollback

Start with the pinned read-only on-demand MCP for one workspace and validate
search quality and provenance. For writes, first run the repository's
interactive fake-Mem0 test harness, then progress through an isolated temporary
Mem0 Project, one trusted repository, and a small team. The shipped Mem0
configuration always targets Mem0 Platform; only the test harness injects a
local endpoint. Enable auto-recall only after the administrator accepts
Platform configuration always targets Mem0 Platform, and only the test harness
injects a local endpoint; a `mem0` (open-source) configuration instead points
at whatever endpoint the operator sets — plain HTTP included when
`allowInsecureHttp` is on — so treat that endpoint and its network whitelist
as the access boundary and validate search against it read-only before
enabling writes. Enable auto-recall only after the administrator accepts
automatic query forwarding. Do not run auto-recall and an on-demand MCP in one
process.

Expand Down
11 changes: 11 additions & 0 deletions integrations/external-context/examples/mem0-oss.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": 1,
"timeoutMs": 5000,
"provider": {
"type": "mem0",
"baseUrl": "https://<your-mem0-endpoint>",
"apiKeyEnv": "MEM0_API_KEY",
"userId": "<your-user-id>",
"appId": "qwen-code"
}
}
1 change: 1 addition & 0 deletions integrations/external-context/src/auto-recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ function isWithin(root: string, candidate: string): boolean {
function providerCredential(config: ProviderConfig): string {
switch (config.type) {
case 'mem0-platform-v3':
case 'mem0':
return config.apiKey;
case 'generic-http-search-v1':
return config.token;
Expand Down
89 changes: 89 additions & 0 deletions integrations/external-context/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,95 @@ describe('loadConfig', () => {
});
});

describe('mem0 OSS provider', () => {
const provider = {
type: 'mem0',
baseUrl: 'https://mem0.example.com:8443',
apiKeyEnv: 'MEM0_API_KEY',
userId: 'fixed-user',
};

it('resolves the credential and fixed user binding', async () => {
const fixture = await createFixture();
await writeConfig(fixture, { version: 1, provider });

await expect(
loadConfig({
QWEN_EXTERNAL_CONTEXT_CONFIG: fixture.config,
MEM0_API_KEY: 'secret-value',
}),
).resolves.toMatchObject({
version: 1,
provider: {
type: 'mem0',
baseUrl: 'https://mem0.example.com:8443',
apiKey: 'secret-value',
userId: 'fixed-user',
},
});
});

it('enables writes for a v1 mem0 provider', async () => {
const fixture = await createFixture();
await writeConfig(fixture, {
version: 1,
write: { enabled: true },
provider,
});

await expect(
loadConfig({
QWEN_EXTERNAL_CONTEXT_CONFIG: fixture.config,
MEM0_API_KEY: 'secret-value',
}),
).resolves.toMatchObject({
version: 1,
write: { enabled: true },
provider: { type: 'mem0' },
});
});

it('loads a plain-HTTP baseUrl with allowInsecureHttp', async () => {
const fixture = await createFixture();
await writeConfig(fixture, {
version: 1,
provider: {
...provider,
baseUrl: 'http://192.0.2.1:8080',
allowInsecureHttp: true,
},
});

await expect(
loadConfig({
QWEN_EXTERNAL_CONTEXT_CONFIG: fixture.config,
MEM0_API_KEY: 'secret-value',
}),
).resolves.toMatchObject({
provider: { type: 'mem0', allowInsecureHttp: true },
});
});

it('rejects a mem0 provider without userId', async () => {
const fixture = await createFixture();
await writeConfig(fixture, {
version: 1,
provider: {
type: 'mem0',
baseUrl: 'https://mem0.example.com:8443',
apiKeyEnv: 'MEM0_API_KEY',
},
});

await expect(
loadConfig({
QWEN_EXTERNAL_CONTEXT_CONFIG: fixture.config,
MEM0_API_KEY: 'secret-value',
}),
).rejects.toThrow('External context config is invalid.');
});
});

it('rejects writes for Generic HTTP and v2 configurations', async () => {
const fixture = await createFixture();
await writeConfig(fixture, {
Expand Down
20 changes: 18 additions & 2 deletions integrations/external-context/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { z } from 'zod';
import type {
ExternalContextConfig,
GenericHttpProviderConfig,
Mem0OssProviderConfig,
Mem0ProviderConfig,
} from './types.js';

Expand All @@ -24,6 +25,16 @@ const providerSchema = z.discriminatedUnion('type', [
appId: z.string().trim().min(1).max(256),
})
.strict(),
z
.object({
type: z.literal('mem0'),
baseUrl: z.string().url(),
apiKeyEnv: z.string().regex(ENV_NAME),
userId: z.string().trim().min(1).max(256),
appId: z.string().trim().min(1).max(256).optional(),
allowInsecureHttp: z.boolean().optional(),
})
.strict(),
z
.object({
type: z.literal('generic-http-search-v1'),
Expand Down Expand Up @@ -114,7 +125,8 @@ export async function loadConfig(
if (result.data.version === 1) {
if (
result.data.write !== undefined &&
result.data.provider.type !== 'mem0-platform-v3'
result.data.provider.type !== 'mem0-platform-v3' &&
result.data.provider.type !== 'mem0'
Comment thread
yiliang114 marked this conversation as resolved.
Outdated
) {
throw new ConfigurationError(
'External context memory writes require a Mem0 provider.',
Expand Down Expand Up @@ -146,12 +158,16 @@ export async function loadConfig(
function resolveProvider(
provider: z.infer<typeof providerSchema>,
env: NodeJS.ProcessEnv,
): Mem0ProviderConfig | GenericHttpProviderConfig {
): Mem0ProviderConfig | Mem0OssProviderConfig | GenericHttpProviderConfig {
switch (provider.type) {
case 'mem0-platform-v3': {
const apiKey = readCredential(env, provider.apiKeyEnv);
return { ...provider, apiKey };
}
case 'mem0': {
const apiKey = readCredential(env, provider.apiKeyEnv);
return { ...provider, apiKey };
}
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
case 'generic-http-search-v1': {
const token = readCredential(env, provider.tokenEnv);
return { ...provider, token };
Expand Down
53 changes: 52 additions & 1 deletion integrations/external-context/src/http-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
*/

import { afterEach, describe, expect, it, vi } from 'vitest';
import { MAX_RESPONSE_BYTES, postJson } from './http-client.js';
import {
MAX_RESPONSE_BYTES,
postJson,
validateProviderBaseUrl,
} from './http-client.js';

// Behavioral net for readBoundedBody's reader loop. The loop was rewritten
// from `for await (const chunk of response.body)` to an explicit getReader()
Expand Down Expand Up @@ -220,3 +224,50 @@ describe('postJson bounded body reading', () => {
);
});
});

describe('validateProviderBaseUrl', () => {
it('accepts HTTPS and loopback HTTP by default', () => {
expect(validateProviderBaseUrl('https://mem0.example.com').protocol).toBe(
'https:',
);
expect(validateProviderBaseUrl('http://127.0.0.1:8080').hostname).toBe(
'127.0.0.1',
);
expect(validateProviderBaseUrl('http://localhost:8080').hostname).toBe(
'localhost',
);
expect(validateProviderBaseUrl('http://[::1]:8080').hostname).toBe('[::1]');
});

it('points at the allowInsecureHttp opt-in when the provider offers one', () => {
expect(() =>
validateProviderBaseUrl('http://192.0.2.1:8080', {
allowInsecureHttpHint: true,
}),
).toThrow('set "allowInsecureHttp": true');
});
Comment thread
yiliang114 marked this conversation as resolved.

it('rejects non-loopback HTTP unless explicitly allowed', () => {
expect(() => validateProviderBaseUrl('http://192.0.2.1:8080')).toThrow(
'Provider URL must use HTTPS or loopback HTTP.',
);
expect(
validateProviderBaseUrl('http://192.0.2.1:8080', {
allowInsecureHttp: true,
}).hostname,
).toBe('192.0.2.1');
});

it('still rejects credentialed and pathed URLs when HTTP is allowed', () => {
expect(() =>
validateProviderBaseUrl('http://key@192.0.2.1:8080', {
allowInsecureHttp: true,
}),
).toThrow('Provider URL must not contain credentials');
expect(() =>
validateProviderBaseUrl('http://192.0.2.1:8080/prefix', {
allowInsecureHttp: true,
}),
).toThrow('Provider URL must not contain credentials, path');
});
});
25 changes: 15 additions & 10 deletions integrations/external-context/src/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export class ProviderHttpStatusError extends Error {
}
}

export function validateProviderBaseUrl(value: string): URL {
export function validateProviderBaseUrl(
value: string,
options?: { allowInsecureHttp?: boolean; allowInsecureHttpHint?: boolean },
): URL {
let url: URL;
try {
url = new URL(value);
Expand All @@ -38,20 +41,22 @@ export function validateProviderBaseUrl(value: string): URL {
'Provider URL must not contain credentials, path, query, or fragment.',
);
}
if (url.protocol === 'https:') {
return url;
}
if (
url.protocol === 'http:' &&
(url.hostname === 'localhost' ||
url.hostname === '127.0.0.1' ||
url.hostname === '[::1]')
url.protocol === 'https:' ||
(url.protocol === 'http:' &&
(url.hostname === 'localhost' ||
url.hostname === '127.0.0.1' ||
url.hostname === '[::1]' ||
options?.allowInsecureHttp === true))
Comment thread
yiliang114 marked this conversation as resolved.
) {
return url;
}
throw new Error('Provider URL must use HTTPS or loopback HTTP.');
throw new Error(
options?.allowInsecureHttpHint === true
Comment thread
yiliang114 marked this conversation as resolved.
Outdated
? 'Provider URL must use HTTPS or loopback HTTP; set "allowInsecureHttp": true to permit plain HTTP for this provider.'
: 'Provider URL must use HTTPS or loopback HTTP.',
);
}

export async function postJson(input: {
url: URL;
authorization: string;
Expand Down
Loading
Loading