Skip to content

Commit aced830

Browse files
authored
Merge pull request #1229 from getlarge/issue-1194-diary-map-app
feat(mcp): human-first diary map exploration app (rebuild #1194, supersedes #1212)
2 parents edc6ca4 + 17a3a33 commit aced830

40 files changed

Lines changed: 2908 additions & 40 deletions

apps/mcp-host-e2e/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
manual/diary-map-url.txt
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/**
2+
* Manual-testing fixture for the diary map MCP app (issue #1194).
3+
*
4+
* Mints a throwaway agent against the RUNNING local e2e stack, seeds a handful
5+
* of namespaced-tag entries, builds a `entries_map_open` payload with a couple
6+
* of pre-interpreted zones, and prints a ready-to-open mcp-host URL. The agent +
7+
* diary persist in the stack (teardown only closes the DB pool), so the printed
8+
* URL stays valid for the life of the stack.
9+
*
10+
* Prereq: the e2e stack must be up (it runs the dockerized mcp-host on :8082):
11+
* COMPOSE_DISABLE_ENV_FILE=true docker compose -f docker-compose.e2e.yaml up -d --build
12+
*
13+
* Run (from repo root):
14+
* pnpm exec nx run @moltnet/mcp-host-e2e:diary-map-fixture
15+
* or directly:
16+
* pnpm --filter @moltnet/mcp-host-e2e exec tsx manual/diary-map-fixture.ts
17+
*
18+
* Then open the printed URL in a browser. See apps/mcp-host/README.md.
19+
*/
20+
import { writeFile } from 'node:fs/promises';
21+
22+
import { createMcpTestHarness } from '@moltnet/mcp-test-harness';
23+
24+
const HOST_BASE = process.env.MCP_HOST_URL ?? 'http://localhost:8082';
25+
26+
interface SeedSpec {
27+
content: string;
28+
title: string;
29+
tags: string[];
30+
entryType: 'semantic' | 'episodic' | 'procedural' | 'reflection';
31+
}
32+
33+
const SEED: SeedSpec[] = [
34+
{
35+
title: 'Chose Drizzle migrations over raw SQL',
36+
content:
37+
'Decided to manage the schema with Drizzle migrations rather than hand-written SQL, for type-safe diffs and a reviewable migration log.',
38+
tags: ['scope:infra', 'topic:database', 'decision'],
39+
entryType: 'semantic',
40+
},
41+
{
42+
title: 'Adopted Ory Keto for authorization',
43+
content:
44+
'Authorization is modeled in Ory Keto (relation tuples) instead of in-app role checks, so policy lives outside the services.',
45+
tags: ['scope:infra', 'scope:auth', 'decision'],
46+
entryType: 'semantic',
47+
},
48+
{
49+
title: 'Postgres + pgvector for hybrid search',
50+
content:
51+
'Picked Postgres with pgvector so semantic + full-text search share one store; avoids a separate vector DB to operate.',
52+
tags: ['scope:infra', 'topic:database', 'topic:search'],
53+
entryType: 'semantic',
54+
},
55+
{
56+
title: 'Reflected on agent autonomy boundaries',
57+
content:
58+
'Thinking through how much an agent should decide alone vs. ask the human — leaning toward explicit, auditable accountability over silent autonomy.',
59+
tags: ['topic:autonomy', 'reflection'],
60+
entryType: 'reflection',
61+
},
62+
{
63+
title: 'Identity is cryptographic, not account-based',
64+
content:
65+
'An agent owns an Ed25519 key; identity and signatures are the source of truth, not a human-owned account row.',
66+
tags: ['topic:autonomy', 'topic:identity', 'decision'],
67+
entryType: 'semantic',
68+
},
69+
{
70+
title: 'Rejected a server-side LLM for exploration',
71+
content:
72+
'Considered running interpretation on the server; rejected it to keep the server deterministic and retrieval-only. The client agent interprets.',
73+
tags: ['topic:research', 'scope:mcp-apps', 'decision'],
74+
entryType: 'semantic',
75+
},
76+
{
77+
title: 'MCP app foundation moved to ext-apps',
78+
content:
79+
'UI apps now build on @modelcontextprotocol/ext-apps in dedicated Vite libs, with a host fixture + browser e2e.',
80+
tags: ['scope:mcp-apps', 'topic:research'],
81+
entryType: 'procedural',
82+
},
83+
];
84+
85+
async function main() {
86+
const harness = await createMcpTestHarness();
87+
const { agent, privateDiaryId, restApiUrl, personalTeamId } = harness;
88+
89+
async function seed(spec: SeedSpec): Promise<string> {
90+
const response = await fetch(
91+
`${restApiUrl}/diaries/${privateDiaryId}/entries`,
92+
{
93+
method: 'POST',
94+
headers: {
95+
'content-type': 'application/json',
96+
authorization: `Bearer ${agent.accessToken}`,
97+
'x-moltnet-team-id': personalTeamId,
98+
},
99+
body: JSON.stringify(spec),
100+
},
101+
);
102+
if (!response.ok) {
103+
throw new Error(
104+
`seed failed: ${response.status} ${await response.text()}`,
105+
);
106+
}
107+
return ((await response.json()) as { id: string }).id;
108+
}
109+
110+
const ids = await Promise.all(SEED.map(seed));
111+
const idOf = (titlePart: string) =>
112+
ids[SEED.findIndex((s) => s.title.includes(titlePart))];
113+
114+
// A pre-interpreted map with three zones, mirroring what the agent would push.
115+
const map = {
116+
diaryName: 'Manual fixture diary',
117+
totalEntries: SEED.length,
118+
sampledEntries: SEED.length,
119+
overview:
120+
'A small diary with three zones: infrastructure decisions, autonomy & identity, and research/MCP-app work.',
121+
zones: [
122+
{
123+
id: 'infra',
124+
label: 'Infrastructure decisions',
125+
why: 'Database, search, and authorization choices that shape the stack.',
126+
territory: 'scope:infra',
127+
entryIds: [idOf('Drizzle'), idOf('Keto'), idOf('pgvector')],
128+
provenance: {
129+
basis: 'tag:scope:infra',
130+
searches: [{ tags: ['scope:infra'] }],
131+
},
132+
},
133+
{
134+
id: 'autonomy',
135+
label: 'Autonomy & identity',
136+
why: 'Reflections and decisions about agent autonomy and cryptographic identity.',
137+
territory: 'topic:autonomy',
138+
entryIds: [idOf('autonomy boundaries'), idOf('cryptographic')],
139+
provenance: {
140+
basis: 'tag:topic:autonomy',
141+
searches: [{ tags: ['topic:autonomy'] }],
142+
},
143+
},
144+
{
145+
id: 'research',
146+
label: 'Research & MCP apps',
147+
why: 'Exploration of the MCP app foundation and design research.',
148+
territory: 'scope:mcp-apps',
149+
entryIds: [idOf('server-side LLM'), idOf('ext-apps')],
150+
provenance: {
151+
basis: 'tag:scope:mcp-apps',
152+
searches: [{ tags: ['scope:mcp-apps'] }],
153+
},
154+
},
155+
],
156+
};
157+
158+
const url = new URL('/', HOST_BASE);
159+
url.searchParams.set('tool', 'entries_map_open');
160+
url.searchParams.set('autorun', '1');
161+
url.searchParams.set('server', `${harness.mcpBaseUrl}/mcp`);
162+
url.searchParams.set('clientId', agent.clientId);
163+
// Throwaway fixture credentials for a local ephemeral stack ONLY. Putting a
164+
// secret in URL params leaks it to browser history / logs / Referer headers —
165+
// never do this with real agent credentials.
166+
url.searchParams.set('clientSecret', agent.clientSecret);
167+
url.searchParams.set(
168+
'args',
169+
JSON.stringify({ diary_id: privateDiaryId, map }),
170+
);
171+
172+
await harness.teardown(); // closes the DB pool; the agent + diary persist.
173+
174+
// Write the URL to a file too — copying a 1.7k single-line URL out of a
175+
// terminal/chat is error-prone (spaces/line-breaks corrupt the JSON args).
176+
const outFile = new URL('./diary-map-url.txt', import.meta.url);
177+
await writeFile(outFile, url.toString() + '\n', 'utf8');
178+
179+
// eslint-disable-next-line no-console
180+
console.log('\n=== Diary map manual fixture ready ===');
181+
// eslint-disable-next-line no-console
182+
console.log(`agent fingerprint : ${agent.keyPair.fingerprint}`);
183+
// eslint-disable-next-line no-console
184+
console.log(`diary id : ${privateDiaryId}`);
185+
// eslint-disable-next-line no-console
186+
console.log(`seeded entries : ${ids.length}`);
187+
// eslint-disable-next-line no-console
188+
console.log(
189+
'\nURL written to apps/mcp-host-e2e/manual/diary-map-url.txt' +
190+
' (cat it / scp it to avoid copy corruption).',
191+
);
192+
// eslint-disable-next-line no-console
193+
console.log(
194+
'\nOver SSH, forward the ports first:\n' +
195+
' ssh -L 8082:localhost:8082 -L 8083:localhost:8083 -L 8001:localhost:8001 <host>\n' +
196+
'then open (the dockerized mcp-host on :8082):\n',
197+
);
198+
// eslint-disable-next-line no-console
199+
console.log(url.toString());
200+
// eslint-disable-next-line no-console
201+
console.log('');
202+
}
203+
204+
main().catch((error: unknown) => {
205+
// eslint-disable-next-line no-console
206+
console.error(
207+
error instanceof Error ? error.message : String(error),
208+
'\n\nIs the e2e stack up? Run:\n COMPOSE_DISABLE_ENV_FILE=true docker compose -f docker-compose.e2e.yaml up -d --build',
209+
);
210+
process.exit(1);
211+
});

apps/mcp-host-e2e/package.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
"dependencies": {
88
"@moltnet/mcp-test-harness": "workspace:*"
99
},
10+
"devDependencies": {
11+
"tsx": "catalog:"
12+
},
1013
"nx": {
1114
"tags": [
1215
"type:e2e",
@@ -15,6 +18,16 @@
1518
],
1619
"implicitDependencies": [
1720
"@moltnet/mcp-host"
18-
]
21+
],
22+
"targets": {
23+
"diary-map-fixture": {
24+
"cache": false,
25+
"executor": "nx:run-commands",
26+
"options": {
27+
"command": "tsx manual/diary-map-fixture.ts",
28+
"cwd": "apps/mcp-host-e2e"
29+
}
30+
}
31+
}
1932
}
2033
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import {
2+
createMcpTestHarness,
3+
type McpTestHarness,
4+
} from '@moltnet/mcp-test-harness';
5+
import { expect, type Page, test } from '@playwright/test';
6+
7+
let harness: McpTestHarness;
8+
const seededEntryIds: string[] = [];
9+
10+
/** Seed a handful of namespaced-tag entries so a zone resolves a real mosaic. */
11+
async function seedEntry(content: string, tags: string[]): Promise<string> {
12+
const response = await fetch(
13+
`${harness.restApiUrl}/diaries/${harness.privateDiaryId}/entries`,
14+
{
15+
method: 'POST',
16+
headers: {
17+
'content-type': 'application/json',
18+
authorization: `Bearer ${harness.agent.accessToken}`,
19+
'x-moltnet-team-id': harness.personalTeamId,
20+
},
21+
body: JSON.stringify({ content, tags, entryType: 'semantic' }),
22+
},
23+
);
24+
if (!response.ok) {
25+
throw new Error(`seed failed: ${response.status} ${await response.text()}`);
26+
}
27+
const body = (await response.json()) as { id: string };
28+
return body.id;
29+
}
30+
31+
test.beforeAll(async () => {
32+
harness = await createMcpTestHarness();
33+
seededEntryIds.push(
34+
await seedEntry('Chose Drizzle migrations over raw SQL.', [
35+
'scope:infra',
36+
'decision',
37+
]),
38+
await seedEntry('Adopted Ory Keto for authorization.', [
39+
'scope:infra',
40+
'scope:auth',
41+
]),
42+
await seedEntry('Reflected on agent autonomy boundaries.', [
43+
'topic:autonomy',
44+
'reflection',
45+
]),
46+
);
47+
});
48+
49+
test.afterAll(async () => {
50+
await harness?.teardown();
51+
});
52+
53+
/** Build the `args` for entries_map_open with a fully-formed map of one zone. */
54+
function mapArgs(): string {
55+
return JSON.stringify({
56+
diary_id: harness.privateDiaryId,
57+
map: {
58+
diaryName: 'e2e diary',
59+
totalEntries: seededEntryIds.length,
60+
sampledEntries: seededEntryIds.length,
61+
overview: 'A small diary with infra decisions and a reflection.',
62+
zones: [
63+
{
64+
id: 'infra',
65+
label: 'Infra decisions',
66+
why: 'Database and authorization choices.',
67+
territory: 'scope:infra',
68+
entryIds: seededEntryIds.slice(0, 2),
69+
provenance: {
70+
basis: 'tag:scope:infra',
71+
searches: [{ tags: ['scope:infra'] }],
72+
},
73+
},
74+
],
75+
},
76+
});
77+
}
78+
79+
async function openMap(page: Page) {
80+
const url = new URL('/', 'http://127.0.0.1:8082');
81+
url.searchParams.set('tool', 'entries_map_open');
82+
url.searchParams.set('autorun', '1');
83+
url.searchParams.set('args', mapArgs());
84+
url.searchParams.set('server', `${harness.mcpBaseUrl}/mcp`);
85+
url.searchParams.set('clientId', harness.agent.clientId);
86+
url.searchParams.set('clientSecret', harness.agent.clientSecret);
87+
await page.goto(url.toString());
88+
89+
await expect(page.locator('#app-state')).toContainText('app-ready');
90+
await expect(page.locator('#app-frame')).toBeVisible();
91+
await expect(page.locator('#result')).toContainText(
92+
'ui://moltnet/entries/explore.html',
93+
);
94+
}
95+
96+
/**
97+
* The app runs two iframes deep: #app-frame loads the sandbox proxy, which
98+
* mounts the app HTML in a nested srcdoc iframe. Reach the app content there.
99+
*/
100+
function appFrame(page: Page) {
101+
return page.frameLocator('#app-frame').frameLocator('iframe');
102+
}
103+
104+
test('mounts the diary map app and serves its resource', async ({ page }) => {
105+
await openMap(page);
106+
const frame = appFrame(page);
107+
// First paint: the overview shows the agent's orientation + the zone card.
108+
await expect(frame.locator('.view-title')).toContainText('e2e diary');
109+
await expect(frame.locator('.zone-card')).toContainText('Infra decisions');
110+
});
111+
112+
test('focusing a zone shows its entry mosaic with a legible count', async ({
113+
page,
114+
}) => {
115+
await openMap(page);
116+
const frame = appFrame(page);
117+
118+
await frame.locator('.zone-card', { hasText: 'Infra decisions' }).click();
119+
120+
// "What am I looking at": header states the zone + a showing-N count.
121+
await expect(frame.locator('.view-title')).toContainText('Infra decisions');
122+
await expect(frame.locator('.view-count')).toContainText('showing');
123+
// The mosaic resolves the two seeded infra entries by id.
124+
await expect(frame.locator('.mosaic')).toBeVisible();
125+
});
126+
127+
test('saving a zone materializes an unpinned draft pack', async ({ page }) => {
128+
await openMap(page);
129+
const frame = appFrame(page);
130+
131+
await frame.locator('.zone-card', { hasText: 'Infra decisions' }).click();
132+
await frame.locator('.pivot.save', { hasText: 'Save this zone' }).click();
133+
134+
// After save the button advances to the validate affordance.
135+
await expect(frame.locator('.pivot.save')).toContainText('validate');
136+
});

0 commit comments

Comments
 (0)