vantage-peers-mcp 2.3.5 → 2.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,109 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.0] — 2026-05-28 — M3 iframeEmbedSessions + __VP_TOOL_RESULT__ stream marker + ack-checklist
4
+
5
+ **Mission instance** : `sigma-vantage-peers-mcp-gui-iframe-embed-v1` (k5730xct6rvrwkvxhy5t5js12d87jwfw).
6
+ **Pi sign-off** : PI_AUTHORIZED_TASK_ID=`k1793m1qgn0zaay6r87dhvsh7187kwya` (PROD-DEPLOY-AUTHORIZED).
7
+ **Eta sign-off** : ETA_APPROVED_TASK_ID=`k171ep964sxabbrgmb21fk9axd87ka1n` at commit `338a7b9e6130ce69dc5fe7f3e2e9ecc4648b4f6a` (Day 79 SHA-pinned).
8
+ **Merge** : PR #545 squash `f509c8d92f0b142bc063a0e9dd070e1993cc729b`.
9
+
10
+ M3 delivers the session registry and stream-marker protocol that connects the VP MCP server
11
+ to the Gen UI iframe bridge. All marker emission is gated behind `VP_EMIT_UI_MARKERS=1`
12
+ so production behaviour is unchanged until the bridge is deployed.
13
+
14
+ ### Convex schema — `iframeEmbedSessions` table
15
+
16
+ NEW table `iframeEmbedSessions` in `convex/schema.ts` :
17
+ - Fields : `sessionId` (string), `tenantId` (optional string), `origin` (string),
18
+ `userId` (optional string), `createdAt` (number), `lastSeenAt` (number),
19
+ `expiresAt` (number), `revoked` (boolean).
20
+ - Indexes : `by_session_id` on `["sessionId"]`, `by_origin_expires` on `["origin", "expiresAt"]`.
21
+
22
+ NEW `convex/iframeEmbedSessions.ts` — 4 operations :
23
+ - `createSession` mutation — inserts a new session row.
24
+ - `getSession` query — returns session or null (null for expired / revoked).
25
+ - `touchSession` mutation — bumps `lastSeenAt` to now; returns bool.
26
+ - `revokeSession` mutation — sets `revoked=true`; returns bool.
27
+
28
+ ### Stream marker — `mcp-server/src/ui-resources/stream-marker.ts`
29
+
30
+ NEW `MARKER_START = "__VP_TOOL_RESULT__"`, `MARKER_END = "__END__"`.
31
+
32
+ NEW `wrapToolResult(payload: VpToolResult): string` :
33
+ - Validates via `VpToolResultSchema`, throws `TypeError` on schema failure.
34
+ - Returns `__VP_TOOL_RESULT__<json>__END__`.
35
+
36
+ NEW `parseToolResult(text: string): VpToolResult | null` :
37
+ - Extracts marker substring (handles bare, embedded, surrounding text).
38
+ - Returns validated `VpToolResult` or null on any failure (no-throw contract).
39
+
40
+ ### MCP tools — marker emission gated by `VP_EMIT_UI_MARKERS=1`
41
+
42
+ `mcp-server/src/tools.ts` — 6 tools now append `wrapToolResult(...)` after the JSON payload
43
+ when `VP_EMIT_UI_MARKERS=1` (default OFF) :
44
+
45
+ | Tool | kind |
46
+ |-----------------------|--------------------|
47
+ | `list_tasks` | `tasks-table` |
48
+ | `list_messages` | `messages-feed` |
49
+ | `get_diary` | `diary-entry` |
50
+ | `list_missions` | `mission-timeline` |
51
+ | `list_briefing_notes` | `briefing-note` |
52
+ | `list_memories` | `memory-quote` |
53
+
54
+ Change is surgical — existing return shape is preserved; marker is appended as a new line.
55
+
56
+ ### Ack checklist
57
+
58
+ NEW `docs/M3-ACK-CHECKLIST.md` — bilingual FR/EN post-deploy verification checklist
59
+ for Marie + Ismaël. Covers: package install, primitive reads, Shadow DOM scoping,
60
+ stream marker emit + parse, bilingual spot check, WCAG AA (contrast + role attrs),
61
+ default-OFF guard.
62
+
63
+ ### Tests
64
+
65
+ 15+ new vitest cases (≥264 total after M3, baseline 253 after M2) :
66
+ - `mcp-server/src/__tests__/m3-stream-marker.test.ts` — 14 cases:
67
+ `wrapToolResult` ×6 valid kinds, ×2 throws on invalid, `parseToolResult` roundtrip,
68
+ non-marker text ×2, embedded text, malformed JSON ×2, schema rejects unknown kind ×2.
69
+ - `convex/iframeEmbedSessions.test.ts` — 7 cases:
70
+ create+get, optional fields, getSession unknown, expired session null,
71
+ touchSession updates lastSeenAt, touchSession unknown false,
72
+ revokeSession marks revoked (getSession null), revokeSession unknown false.
73
+
74
+ 0 regression on M1+M2 suites (253/253 baseline).
75
+
76
+ ---
77
+
78
+ ## [Unreleased] — M1 SEP-1865 ui:// resources backend + M2 primitives + Zod schemas
79
+
80
+ **Mission instance** : `sigma-vantage-peers-mcp-gui-iframe-embed-v1` (k5730xct6rvrwkvxhy5t5js12d87jwfw).
81
+ **Template VR consumed** : `gui-iframe-embed-v1` v1.0.0 (jx7bzk0x1086tgwgj2zrssk2pn87k1ga).
82
+
83
+ M1 Foundation (adapted MCP-pure paradigm per Pi arbitrage Day 84) :
84
+ - NEW `mcp-server/src/ui-resources/index.ts` : URI parser `ui://vp/v1/<primitive>?<query>` + primitive registry + handler factory.
85
+ - NEW `mcp-server/src/ui-resources/primitives/tasks-table.ts` : M1 MVP primitive returning HTML inline (Shadow DOM scoped CSS) — WCAG AA + bilingual FR+EN.
86
+ - `mcp-server/server-http.ts` : wired `ListResourcesRequestSchema` + `ReadResourceRequestSchema` MCP handlers on the existing McpServer instance.
87
+
88
+ Tests : 14 new vitest cases (`src/__tests__/ui-resources-sep-1865.test.ts`) — URI parsing, primitive registry, render variants (empty, populated, FR), backend arg forwarding, XSS escape, error fallback, limit clamping, unknown primitive rejection. 0 regression on existing suites.
89
+
90
+ ### M2 — Resolve 5 Gaps + Bearer sha256 hardening (adapted MCP-pure paradigm)
91
+
92
+ 5 new ui:// primitives :
93
+ - `messages-feed` (`messages:listMessages` backend — channel filter applied client-side)
94
+ - `diary-entry` (`diary:get` single-entry + `diary:list` multi-entry backend)
95
+ - `mission-timeline` (`missions:list` backend with fields=lite)
96
+ - `briefing-note` (`briefingNotes:get` by noteId OR `briefingNotes:list` by topic backend)
97
+ - `memory-quote` (`memories:listMemories` backend — supports both plain-array and paginated result shapes)
98
+
99
+ Zod discriminated union schemas : `mcp-server/src/ui-resources/schemas.ts` exports `VpTaskPayloadSchema` + `VpMessagePayloadSchema` + `VpDiaryEntryPayloadSchema` + `VpMissionPayloadSchema` + `VpBriefingNotePayloadSchema` + `VpMemoryPayloadSchema` + `VpToolResultSchema` (discriminated union by `kind`). Cross-fleet ready for Mu vantage-bridge sidepanel S3 consumer.
100
+
101
+ Bearer sha256 validation : Already in place since v2.3.4 DCR security fix. `mcp-server/src/auth.ts` line 275 calls `sha256Hex(token)` before every Convex lookup (layers 2 and 4). Raw token never reaches Convex. No further hardening needed in M2.
102
+
103
+ Tests : 42 new vitest cases in `src/__tests__/ui-resources-m2-primitives.test.ts` (target was ≥22). Covers : PRIMITIVES registry (6 entries), each of 5 new primitives (empty + populated + FR labels + XSS escape + error fallback = 5 cases each), Zod schema roundtrip (VpToolResultSchema all 6 variants accepted, malformed rejected, individual payload schema validations). 0 regression on M1 17 cases + 194 other MCP tests (253/253 total).
104
+
105
+ M3 next : Registry json-render + `__VP_TOOL_RESULT__<json>` stream marker + smoke E2E + ack-checklist + PI-SIGNED Convex prod deploy + visual ack Marie/Ismaël.
106
+
3
107
  ## v2.3.5 — 2026-05-28
4
108
 
5
109
  **Critical hotfix** — v2.3.3 (PR #539) shipped the backend filters `createdBy` + `updatedSince` and the Zod schema exports but did NOT wire those params into the 4 list MCP tool args blocks. Pi pull-cycle quickstart `list_tasks createdBy="pi" status="review" fields="lite"` was silently dropping `createdBy` at the MCP boundary and returning all visible tasks. Auto-clamp safeguard (Day 83) also could not trigger because Zod `.default(50)` / `.default(20)` on `limit` overrode the absent-value signal before it reached the backend.
@@ -25,13 +25,14 @@
25
25
  * NODE_ENV — set to "production" on Railway
26
26
  */
27
27
  import { readFileSync } from "node:fs";
28
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
28
+ import { McpServer, ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js";
29
29
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
30
30
  import { ConvexHttpClient } from "convex/browser";
31
31
  import { Hono } from "hono";
32
32
  import { cors } from "hono/cors";
33
33
  import { bearerAuthMiddleware, internalClient, masterOnlyMiddleware, sha256Base64Url, sha256Hex, } from "./src/auth.js";
34
34
  import { registerTools } from "./src/tools.js";
35
+ import { listUiResources, readUiResource } from "./src/ui-resources/index.js";
35
36
  let pkg;
36
37
  try {
37
38
  // Source mode: server-http.ts → ./package.json = mcp-server/package.json
@@ -563,6 +564,31 @@ app.all("/mcp", bearerAuthMiddleware(), async (c) => {
563
564
  version: pkg.version,
564
565
  });
565
566
  registerTools(server, convex, oauthCtx);
567
+ // SEP-1865 ui:// resources for Generative UI primitives
568
+ // Uses McpServer.resource() high-level API with a ResourceTemplate so that
569
+ // resources/list (via listCallback) and resources/read both work.
570
+ // URI pattern: ui://vp/v1/{primitive} — query params read from the URL object.
571
+ const uiResourceTemplate = new ResourceTemplate("ui://vp/v1/{primitive}", {
572
+ list: async () => ({ resources: listUiResources() }),
573
+ });
574
+ server.resource("vp-ui", uiResourceTemplate, {
575
+ description: "SEP-1865 VantagePeers Generative UI primitives (HTML inline, Shadow DOM scoped)",
576
+ }, async (uri) => {
577
+ const fetchConvex = async (functionName, args) => {
578
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
579
+ return convex.query(functionName, args);
580
+ };
581
+ const resource = await readUiResource(uri.toString(), fetchConvex);
582
+ return {
583
+ contents: [
584
+ {
585
+ uri: resource.uri,
586
+ mimeType: resource.mimeType,
587
+ text: resource.text,
588
+ },
589
+ ],
590
+ };
591
+ });
566
592
  const transport = new WebStandardStreamableHTTPServerTransport();
567
593
  await server.connect(transport);
568
594
  return transport.handleRequest(c.req.raw);
package/dist/src/tools.js CHANGED
@@ -10,6 +10,36 @@
10
10
  import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
11
11
  import { z } from "zod";
12
12
  import { checkFromAllowed, checkNamespaceRead, checkNamespaceWrite, isMasterScope, } from "./auth.js";
13
+ import { wrapToolResult } from "./ui-resources/stream-marker.js";
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ // VP_EMIT_UI_MARKERS gate
16
+ //
17
+ // When VP_EMIT_UI_MARKERS=1 the 6 list/get tools that have a matching
18
+ // ui:// primitive append a __VP_TOOL_RESULT__<json>__END__ marker after the
19
+ // existing JSON payload. The Gen UI iframe bridge detects this marker and
20
+ // renders the structured primitive inline. Default is OFF so prod behaviour
21
+ // is unchanged.
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+ const UI_MARKERS_ENABLED = process.env.VP_EMIT_UI_MARKERS === "1" ||
24
+ process.env.VP_EMIT_UI_MARKERS === "true";
25
+ /**
26
+ * Append a stream marker to a text response when UI markers are enabled.
27
+ * `buildPayload` is called only when the flag is ON to avoid any overhead.
28
+ */
29
+ function appendMarkerIfEnabled(text, buildPayload) {
30
+ if (!UI_MARKERS_ENABLED)
31
+ return text;
32
+ try {
33
+ const payload = buildPayload();
34
+ if (payload === null)
35
+ return text;
36
+ return `${text}\n${wrapToolResult(payload)}`;
37
+ }
38
+ catch {
39
+ // Never break the primary response — marker emission is best-effort.
40
+ return text;
41
+ }
42
+ }
13
43
  // ─────────────────────────────────────────────────────────────────────────────
14
44
  // Pre-flight content size guard
15
45
  //
@@ -746,13 +776,24 @@ export function registerTools(server, convex, oauthCtx) {
746
776
  type,
747
777
  limit: limit ?? 20,
748
778
  });
779
+ const rawList = Array.isArray(memories)
780
+ ? memories
781
+ : Array.isArray(memories?.page)
782
+ ? memories.page
783
+ : [];
784
+ const baseText = JSON.stringify(memories, null, 2);
785
+ const text = appendMarkerIfEnabled(baseText, () => ({
786
+ kind: "memory-quote",
787
+ items: rawList.map((m) => ({
788
+ _id: m._id,
789
+ namespace: m.namespace,
790
+ type: m.type,
791
+ content: m.content,
792
+ score: m.score,
793
+ })),
794
+ }));
749
795
  return {
750
- content: [
751
- {
752
- type: "text",
753
- text: JSON.stringify(memories, null, 2),
754
- },
755
- ],
796
+ content: [{ type: "text", text }],
756
797
  };
757
798
  }
758
799
  catch (error) {
@@ -1025,13 +1066,21 @@ export function registerTools(server, convex, oauthCtx) {
1025
1066
  from,
1026
1067
  limit: limit ?? 100,
1027
1068
  });
1069
+ const baseText = JSON.stringify(messages, null, 2);
1070
+ const text = appendMarkerIfEnabled(baseText, () => ({
1071
+ kind: "messages-feed",
1072
+ items: Array.isArray(messages)
1073
+ ? messages.map((m) => ({
1074
+ _id: m._id,
1075
+ from: m.from,
1076
+ channel: m.channel,
1077
+ content: m.content,
1078
+ createdAt: m.createdAt,
1079
+ }))
1080
+ : [],
1081
+ }));
1028
1082
  return {
1029
- content: [
1030
- {
1031
- type: "text",
1032
- text: JSON.stringify(messages, null, 2),
1033
- },
1034
- ],
1083
+ content: [{ type: "text", text }],
1035
1084
  };
1036
1085
  }
1037
1086
  catch (error) {
@@ -1169,13 +1218,22 @@ export function registerTools(server, convex, oauthCtx) {
1169
1218
  createdBy,
1170
1219
  updatedSince,
1171
1220
  });
1221
+ const baseText = JSON.stringify(tasks, null, 2);
1222
+ const text = appendMarkerIfEnabled(baseText, () => ({
1223
+ kind: "tasks-table",
1224
+ items: Array.isArray(tasks)
1225
+ ? tasks.map((t) => ({
1226
+ _id: t._id,
1227
+ title: t.title,
1228
+ status: t.status,
1229
+ priority: t.priority,
1230
+ assignedTo: t.assignedTo,
1231
+ _creationTime: t._creationTime,
1232
+ }))
1233
+ : [],
1234
+ }));
1172
1235
  return {
1173
- content: [
1174
- {
1175
- type: "text",
1176
- text: JSON.stringify(tasks, null, 2),
1177
- },
1178
- ],
1236
+ content: [{ type: "text", text }],
1179
1237
  };
1180
1238
  }
1181
1239
  catch (error) {
@@ -1598,13 +1656,23 @@ export function registerTools(server, convex, oauthCtx) {
1598
1656
  fields,
1599
1657
  updatedSince,
1600
1658
  });
1659
+ const baseText = JSON.stringify(missions, null, 2);
1660
+ const text = appendMarkerIfEnabled(baseText, () => ({
1661
+ kind: "mission-timeline",
1662
+ items: Array.isArray(missions)
1663
+ ? missions.map((m) => ({
1664
+ _id: m._id,
1665
+ name: m.name,
1666
+ project: m.project,
1667
+ status: m.status,
1668
+ pilot: m.pilot,
1669
+ priority: m.priority,
1670
+ progress: m.progress,
1671
+ }))
1672
+ : [],
1673
+ }));
1601
1674
  return {
1602
- content: [
1603
- {
1604
- type: "text",
1605
- text: JSON.stringify(missions, null, 2),
1606
- },
1607
- ],
1675
+ content: [{ type: "text", text }],
1608
1676
  };
1609
1677
  }
1610
1678
  catch (error) {
@@ -1759,13 +1827,24 @@ export function registerTools(server, convex, oauthCtx) {
1759
1827
  date,
1760
1828
  orchestrator,
1761
1829
  });
1762
- return {
1763
- content: [
1764
- {
1765
- type: "text",
1766
- text: JSON.stringify(entry, null, 2),
1830
+ const baseText = JSON.stringify(entry, null, 2);
1831
+ const text = appendMarkerIfEnabled(baseText, () => {
1832
+ if (!entry)
1833
+ return null;
1834
+ return {
1835
+ kind: "diary-entry",
1836
+ item: {
1837
+ _id: entry._id,
1838
+ date: entry.date,
1839
+ orchestrator: entry.orchestrator,
1840
+ content: entry.content,
1841
+ highlights: entry.highlights,
1842
+ blockers: entry.blockers,
1767
1843
  },
1768
- ],
1844
+ };
1845
+ });
1846
+ return {
1847
+ content: [{ type: "text", text }],
1769
1848
  };
1770
1849
  }
1771
1850
  catch (error) {
@@ -1931,13 +2010,27 @@ export function registerTools(server, convex, oauthCtx) {
1931
2010
  fields,
1932
2011
  updatedSince,
1933
2012
  });
1934
- return {
1935
- content: [
1936
- {
1937
- type: "text",
1938
- text: JSON.stringify(notes, null, 2),
2013
+ const baseText = JSON.stringify(notes, null, 2);
2014
+ const text = appendMarkerIfEnabled(baseText, () => {
2015
+ const items = Array.isArray(notes) ? notes : [];
2016
+ if (items.length === 0)
2017
+ return null;
2018
+ // Emit the first note as a briefing-note item for the primitive renderer.
2019
+ const first = items[0];
2020
+ return {
2021
+ kind: "briefing-note",
2022
+ item: {
2023
+ _id: first._id,
2024
+ topic: first.topic,
2025
+ title: first.title,
2026
+ participants: first.participants,
2027
+ content: first.content,
2028
+ createdBy: first.createdBy,
1939
2029
  },
1940
- ],
2030
+ };
2031
+ });
2032
+ return {
2033
+ content: [{ type: "text", text }],
1941
2034
  };
1942
2035
  }
1943
2036
  catch (error) {
@@ -0,0 +1,36 @@
1
+ /**
2
+ * SEP-1865 ui:// resources for VantagePeers Generative UI.
3
+ *
4
+ * URI pattern : ui://vp/v1/<primitive>?<query>
5
+ * Examples :
6
+ * ui://vp/v1/tasks-table?assignedTo=pi&status=review&fields=lite&limit=10
7
+ * ui://vp/v1/messages-feed?recipient=sigma&limit=20
8
+ *
9
+ * M1 scope : 1 primitive (tasks-table) — proves the pipeline.
10
+ * M2 scope : ≥6 primitives (tasks/messages/diary/missions/briefingNotes/memories).
11
+ *
12
+ * Pattern Hybrid 60% static lit-ui + 11% Gen UI + 27% Hybrid (cf vp-gui-views-research-2026-05-28.md).
13
+ * Returns HTML inline with embedded JS + CSS Shadow DOM scoped.
14
+ *
15
+ * Reference instance Theta : theta-vantage-crm-gui-iframe-embed-v1 (blissful-gopher-531).
16
+ * Mission Sigma : sigma-vantage-peers-mcp-gui-iframe-embed-v1 (k5730xct6rvrwkvxhy5t5js12d87jwfw).
17
+ */
18
+ export type UiResourceParsed = {
19
+ primitive: string;
20
+ query: URLSearchParams;
21
+ };
22
+ export declare function parseUiUri(uri: string): UiResourceParsed | null;
23
+ export declare const PRIMITIVES: readonly ["tasks-table", "messages-feed", "diary-entry", "mission-timeline", "briefing-note", "memory-quote"];
24
+ export type Primitive = (typeof PRIMITIVES)[number];
25
+ export declare const PRIMITIVE_DESCRIPTIONS: Record<Primitive, string>;
26
+ export declare function listUiResources(): Array<{
27
+ uri: string;
28
+ name: string;
29
+ description: string;
30
+ mimeType: string;
31
+ }>;
32
+ export declare function readUiResource(uri: string, fetchConvex: (functionName: string, args: Record<string, unknown>) => Promise<unknown>): Promise<{
33
+ uri: string;
34
+ mimeType: string;
35
+ text: string;
36
+ }>;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * SEP-1865 ui:// resources for VantagePeers Generative UI.
3
+ *
4
+ * URI pattern : ui://vp/v1/<primitive>?<query>
5
+ * Examples :
6
+ * ui://vp/v1/tasks-table?assignedTo=pi&status=review&fields=lite&limit=10
7
+ * ui://vp/v1/messages-feed?recipient=sigma&limit=20
8
+ *
9
+ * M1 scope : 1 primitive (tasks-table) — proves the pipeline.
10
+ * M2 scope : ≥6 primitives (tasks/messages/diary/missions/briefingNotes/memories).
11
+ *
12
+ * Pattern Hybrid 60% static lit-ui + 11% Gen UI + 27% Hybrid (cf vp-gui-views-research-2026-05-28.md).
13
+ * Returns HTML inline with embedded JS + CSS Shadow DOM scoped.
14
+ *
15
+ * Reference instance Theta : theta-vantage-crm-gui-iframe-embed-v1 (blissful-gopher-531).
16
+ * Mission Sigma : sigma-vantage-peers-mcp-gui-iframe-embed-v1 (k5730xct6rvrwkvxhy5t5js12d87jwfw).
17
+ */
18
+ import { renderBriefingNote } from "./primitives/briefing-note.js";
19
+ import { renderDiaryEntry } from "./primitives/diary-entry.js";
20
+ import { renderMemoryQuote } from "./primitives/memory-quote.js";
21
+ import { renderMessagesFeed } from "./primitives/messages-feed.js";
22
+ import { renderMissionTimeline } from "./primitives/mission-timeline.js";
23
+ import { renderTasksTable } from "./primitives/tasks-table.js";
24
+ // URI parser : ui://vp/v1/<primitive>?<query>
25
+ const UI_URI_RE = /^ui:\/\/vp\/v1\/([a-z][a-z0-9-]*)(?:\?(.*))?$/;
26
+ export function parseUiUri(uri) {
27
+ const match = UI_URI_RE.exec(uri);
28
+ if (!match)
29
+ return null;
30
+ const primitive = match[1];
31
+ const queryString = match[2] ?? "";
32
+ return {
33
+ primitive,
34
+ query: new URLSearchParams(queryString),
35
+ };
36
+ }
37
+ // Primitive registry — M1 ships 1 (tasks-table). M2 adds 5 more.
38
+ export const PRIMITIVES = [
39
+ "tasks-table",
40
+ "messages-feed",
41
+ "diary-entry",
42
+ "mission-timeline",
43
+ "briefing-note",
44
+ "memory-quote",
45
+ ];
46
+ export const PRIMITIVE_DESCRIPTIONS = {
47
+ "tasks-table": "Render a compact table of tasks. Query params: assignedTo, status, fields=lite|full, limit. Mirrors list_tasks tool semantics.",
48
+ "messages-feed": "Render a chronological feed of VantagePeers messages. Query params: from, channel, limit, lang.",
49
+ "diary-entry": "Render a single diary entry or list of recent entries. Query params: date (YYYY-MM-DD), orchestrator, limit, lang.",
50
+ "mission-timeline": "Render a missions timeline. Query params: pilot, project, status, limit, lang.",
51
+ "briefing-note": "Render briefing note details. Query params: noteId or (topic + limit), lang.",
52
+ "memory-quote": "Render memory quotes from a namespace. Query params: namespace, type, limit, lang.",
53
+ };
54
+ // Resource list — returned by resources/list MCP handler
55
+ export function listUiResources() {
56
+ return PRIMITIVES.map((p) => ({
57
+ uri: `ui://vp/v1/${p}`,
58
+ name: p,
59
+ description: PRIMITIVE_DESCRIPTIONS[p],
60
+ mimeType: "text/html",
61
+ }));
62
+ }
63
+ // Resource read — dispatched by primitive name. Returns HTML inline.
64
+ export async function readUiResource(uri, fetchConvex) {
65
+ const parsed = parseUiUri(uri);
66
+ if (!parsed) {
67
+ throw new Error(`[VP UI Resources] Invalid ui:// URI: ${uri}`);
68
+ }
69
+ if (!PRIMITIVES.includes(parsed.primitive)) {
70
+ throw new Error(`[VP UI Resources] Unknown primitive: ${parsed.primitive}. Available: ${PRIMITIVES.join(", ")}`);
71
+ }
72
+ let html;
73
+ switch (parsed.primitive) {
74
+ case "tasks-table":
75
+ html = await renderTasksTable(parsed.query, fetchConvex);
76
+ break;
77
+ case "messages-feed":
78
+ html = await renderMessagesFeed(parsed.query, fetchConvex);
79
+ break;
80
+ case "diary-entry":
81
+ html = await renderDiaryEntry(parsed.query, fetchConvex);
82
+ break;
83
+ case "mission-timeline":
84
+ html = await renderMissionTimeline(parsed.query, fetchConvex);
85
+ break;
86
+ case "briefing-note":
87
+ html = await renderBriefingNote(parsed.query, fetchConvex);
88
+ break;
89
+ case "memory-quote":
90
+ html = await renderMemoryQuote(parsed.query, fetchConvex);
91
+ break;
92
+ default:
93
+ throw new Error(`[VP UI Resources] Unimplemented primitive: ${parsed.primitive}`);
94
+ }
95
+ return {
96
+ uri,
97
+ mimeType: "text/html",
98
+ text: html,
99
+ };
100
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * briefing-note primitive — SEP-1865 ui:// resource.
3
+ * Renders a briefing note detail card or list of recent notes.
4
+ *
5
+ * Query params :
6
+ * noteId : Convex ID for a specific note (optional)
7
+ * topic : topic filter — used when noteId not provided (optional)
8
+ * limit : 1-100 (default 20) — used when noteId not provided
9
+ * lang : "en" (default) | "fr"
10
+ *
11
+ * Backend :
12
+ * briefingNotes:get — when noteId provided
13
+ * briefingNotes:list — when topic or no params
14
+ *
15
+ * Output : HTML card(s) wrapped in <div class="vp-briefing-note"> with embedded CSS.
16
+ * WCAG AA + bilingual FR+EN labels.
17
+ */
18
+ export declare function renderBriefingNote(query: URLSearchParams, fetchConvex: (functionName: string, args: Record<string, unknown>) => Promise<unknown>): Promise<string>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * briefing-note primitive — SEP-1865 ui:// resource.
3
+ * Renders a briefing note detail card or list of recent notes.
4
+ *
5
+ * Query params :
6
+ * noteId : Convex ID for a specific note (optional)
7
+ * topic : topic filter — used when noteId not provided (optional)
8
+ * limit : 1-100 (default 20) — used when noteId not provided
9
+ * lang : "en" (default) | "fr"
10
+ *
11
+ * Backend :
12
+ * briefingNotes:get — when noteId provided
13
+ * briefingNotes:list — when topic or no params
14
+ *
15
+ * Output : HTML card(s) wrapped in <div class="vp-briefing-note"> with embedded CSS.
16
+ * WCAG AA + bilingual FR+EN labels.
17
+ */
18
+ // Minimal escape — avoid XSS in injected content
19
+ function esc(s) {
20
+ return s.replace(/[&<>"']/g, (c) => {
21
+ switch (c) {
22
+ case "&":
23
+ return "&amp;";
24
+ case "<":
25
+ return "&lt;";
26
+ case ">":
27
+ return "&gt;";
28
+ case '"':
29
+ return "&quot;";
30
+ case "'":
31
+ return "&#39;";
32
+ default:
33
+ return c;
34
+ }
35
+ });
36
+ }
37
+ function renderNoteCard(note, lang) {
38
+ const participantsLabel = lang === "fr" ? "Participants" : "Participants";
39
+ const byLabel = lang === "fr" ? "Par" : "By";
40
+ const participantsHtml = note.participants && note.participants.length > 0
41
+ ? `<div class="vp-briefing-participants">
42
+ <span class="vp-briefing-label">${esc(participantsLabel)} :</span>
43
+ ${note.participants.map((p) => `<span class="vp-briefing-pill">${esc(p)}</span>`).join(" ")}
44
+ </div>`
45
+ : "";
46
+ const createdByHtml = note.createdBy
47
+ ? `<div class="vp-briefing-meta">${esc(byLabel)}: ${esc(note.createdBy)}</div>`
48
+ : "";
49
+ const contentHtml = note.content
50
+ ? `<div class="vp-briefing-content">${esc(note.content)}</div>`
51
+ : "";
52
+ return `<article class="vp-briefing-card" aria-label="${esc(note.title)}">
53
+ <header class="vp-briefing-header">
54
+ <span class="vp-briefing-topic">${esc(note.topic)}</span>
55
+ <h3 class="vp-briefing-title">${esc(note.title)}</h3>
56
+ </header>
57
+ ${participantsHtml}
58
+ ${contentHtml}
59
+ ${createdByHtml}
60
+ </article>`;
61
+ }
62
+ export async function renderBriefingNote(query, fetchConvex) {
63
+ const noteId = query.get("noteId") ?? undefined;
64
+ const topic = query.get("topic") ?? undefined;
65
+ const limitRaw = query.get("limit");
66
+ const limitParsed = limitRaw !== null ? Number.parseInt(limitRaw, 10) : Number.NaN;
67
+ const limit = Number.isNaN(limitParsed)
68
+ ? 20
69
+ : Math.min(100, Math.max(1, limitParsed));
70
+ const lang = (query.get("lang") ?? "en").toLowerCase();
71
+ const heading = lang === "fr"
72
+ ? "Notes de briefing VantagePeers"
73
+ : "VantagePeers Briefing Notes";
74
+ const emptyLabel = lang === "fr"
75
+ ? "Aucune note de briefing trouvée."
76
+ : "No briefing notes found.";
77
+ let notes = [];
78
+ try {
79
+ if (noteId) {
80
+ const result = (await fetchConvex("briefingNotes:get", {
81
+ noteId,
82
+ }));
83
+ notes = result ? [result] : [];
84
+ }
85
+ else {
86
+ const args = { limit };
87
+ if (topic)
88
+ args.topic = topic;
89
+ const result = (await fetchConvex("briefingNotes:list", args));
90
+ notes = Array.isArray(result) ? result : [];
91
+ }
92
+ }
93
+ catch (err) {
94
+ const msg = err instanceof Error ? err.message : String(err);
95
+ return `<div class="vp-briefing-note-error" role="alert">${esc(msg)}</div>`;
96
+ }
97
+ const style = `<style>
98
+ .vp-briefing-note { font-family: system-ui, -apple-system, sans-serif; font-size: 13px; color: #1f2328; }
99
+ .vp-briefing-card { border: 1px solid #d0d7de; border-radius: 8px; padding: 16px; margin-bottom: 12px; background: #fff; }
100
+ .vp-briefing-header { margin-bottom: 10px; }
101
+ .vp-briefing-topic { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 500; background: #ddf4ff; color: #0969da; margin-bottom: 6px; }
102
+ .vp-briefing-title { font-size: 14px; font-weight: 600; margin: 0; }
103
+ .vp-briefing-participants { margin-top: 8px; display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
104
+ .vp-briefing-label { color: #656d76; }
105
+ .vp-briefing-pill { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; background: #f6f8fa; border: 1px solid #d0d7de; }
106
+ .vp-briefing-content { margin-top: 10px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; }
107
+ .vp-briefing-meta { margin-top: 8px; color: #656d76; font-size: 12px; }
108
+ .vp-briefing-count { color: #656d76; font-size: 12px; margin-top: 8px; }
109
+ .vp-briefing-empty { color: #656d76; padding: 12px 0; }
110
+ </style>`;
111
+ if (notes.length === 0) {
112
+ return `<div class="vp-briefing-note" role="region" aria-label="${esc(heading)}">
113
+ ${style}
114
+ <p class="vp-briefing-empty">${esc(emptyLabel)}</p>
115
+ </div>`;
116
+ }
117
+ const cards = notes.map((n) => renderNoteCard(n, lang)).join("\n");
118
+ const countLabel = lang === "fr"
119
+ ? `${notes.length} note${notes.length === 1 ? "" : "s"}`
120
+ : `${notes.length} note${notes.length === 1 ? "" : "s"}`;
121
+ return `<div class="vp-briefing-note" role="region" aria-label="${esc(heading)}">
122
+ ${style}
123
+ ${cards}
124
+ <div class="vp-briefing-count" aria-live="polite">${esc(countLabel)}</div>
125
+ </div>`;
126
+ }