vantage-peers-mcp 2.4.12 → 2.4.14
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 +33 -12
- package/dist/server-http.d.ts +14 -1
- package/dist/server-http.js +126 -23
- package/dist/src/crypto.d.ts +23 -0
- package/dist/src/crypto.js +39 -0
- package/dist/src/scope-filter.d.ts +65 -0
- package/dist/src/scope-filter.js +84 -0
- package/dist/src/tools.js +21 -6
- package/dist/src/ui-resources/index.d.ts +31 -8
- package/dist/src/ui-resources/index.js +53 -11
- package/package.json +18 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [2.4.
|
|
3
|
+
## [2.4.13] — 2026-06-02 — Post-public republish: attribution + CHANGELOG day-numbers + RULE #7 narrative scrub
|
|
4
|
+
|
|
5
|
+
Repository visibility flip to PUBLIC on 2026-06-02 (mission D62 `k57e4t21sr55rhz8ng554eseb987wvh3`). This patch republishes the npm package so the published README + CHANGELOG + attribution match the now-public source.
|
|
6
|
+
|
|
7
|
+
No runtime / API / schema changes. Documentation + metadata only.
|
|
8
|
+
|
|
9
|
+
What changed since v2.4.12:
|
|
10
|
+
- `mcp-server/package.json`: author restructured to "VantageOS AI Orchestrator Team" with contributors block (Pi, Laurent Perello, ElPi Corp). Dependency `@vantageos/mosaic@^0.1.2` added for Phase 1 Mosaic groundwork (PR #605, server-side createMosaicResource API ready for Phase 2 primitive swap).
|
|
11
|
+
- `mcp-server/CHANGELOG.md`: version headers simplified to `X.Y.Z — YYYY-MM-DD` (Day N anchors dropped per Laurent verdict 2026-06-02 — dates are self-explanatory, day numbers added noise). Narrative client-name mentions (Marie/Iris RH/Cédric Delport) genericized to "early-access RH cohort" / "self-host incident" per RULE #7 pre-public scrub.
|
|
12
|
+
- Root README rework (PR #611 + PR #610 + PR #616 chain): TL;DR + Mermaid architecture diagram + 5 hero features + 22-features collapsed details + 84-tools 8-groups + Backend: Convex 3-paths + attribution Credits section. README /team 404 hotfix landed in PR #616.
|
|
13
|
+
|
|
14
|
+
Merged PRs in this republish window:
|
|
15
|
+
- PR #611 (`9464f9a`) — T5ter README rework + CHANGELOG day-numbers + attribution
|
|
16
|
+
- PR #615 (`c189a1d`) — Phase 1 RULE #7 pre-public scrub
|
|
17
|
+
- PR #616 (`99eeae5`) — README /team 404 hotfix
|
|
18
|
+
|
|
19
|
+
Mission: D62 pre-public cleanup `k57e4t21sr55rhz8ng554eseb987wvh3`.
|
|
20
|
+
Friction capitalize: `post-public-flip-must-trigger-npm-republish-for-consistency-not-just-repo-visibility-flip` + `day-79-hook-should-validate-tree-not-commit-sha`.
|
|
21
|
+
|
|
22
|
+
## [2.4.0] — 2026-05-29 — M3 iframeEmbedSessions + __VP_TOOL_RESULT__ stream marker + ack-checklist
|
|
4
23
|
|
|
5
24
|
**Mission instance** : `sigma-vantage-peers-mcp-gui-iframe-embed-v1` (k5730xct6rvrwkvxhy5t5js12d87jwfw).
|
|
6
25
|
**Pi sign-off** : PI_AUTHORIZED_TASK_ID=`k1793m1qgn0zaay6r87dhvsh7187kwya` (PROD-DEPLOY-AUTHORIZED).
|
|
7
|
-
**Eta sign-off** : ETA_APPROVED_TASK_ID=`k171ep964sxabbrgmb21fk9axd87ka1n` at commit `338a7b9e6130ce69dc5fe7f3e2e9ecc4648b4f6a` (
|
|
26
|
+
**Eta sign-off** : ETA_APPROVED_TASK_ID=`k171ep964sxabbrgmb21fk9axd87ka1n` at commit `338a7b9e6130ce69dc5fe7f3e2e9ecc4648b4f6a` (SHA-pinned).
|
|
8
27
|
**Merge** : PR #545 squash `f509c8d92f0b142bc063a0e9dd070e1993cc729b`.
|
|
9
28
|
|
|
10
29
|
M3 delivers the session registry and stream-marker protocol that connects the VP MCP server
|
|
@@ -56,7 +75,7 @@ Change is surgical — existing return shape is preserved; marker is appended as
|
|
|
56
75
|
### Ack checklist
|
|
57
76
|
|
|
58
77
|
NEW `docs/M3-ACK-CHECKLIST.md` — bilingual FR/EN post-deploy verification checklist
|
|
59
|
-
for
|
|
78
|
+
for the beta verifier cohort. Covers: package install, primitive reads, Shadow DOM scoping,
|
|
60
79
|
stream marker emit + parse, bilingual spot check, WCAG AA (contrast + role attrs),
|
|
61
80
|
default-OFF guard.
|
|
62
81
|
|
|
@@ -80,7 +99,7 @@ default-OFF guard.
|
|
|
80
99
|
**Mission instance** : `sigma-vantage-peers-mcp-gui-iframe-embed-v1` (k5730xct6rvrwkvxhy5t5js12d87jwfw).
|
|
81
100
|
**Template VR consumed** : `gui-iframe-embed-v1` v1.0.0 (jx7bzk0x1086tgwgj2zrssk2pn87k1ga).
|
|
82
101
|
|
|
83
|
-
M1 Foundation (adapted MCP-pure paradigm per Pi arbitrage
|
|
102
|
+
M1 Foundation (adapted MCP-pure paradigm per Pi arbitrage 2026-05-28) :
|
|
84
103
|
- NEW `mcp-server/src/ui-resources/index.ts` : URI parser `ui://vp/v1/<primitive>?<query>` + primitive registry + handler factory.
|
|
85
104
|
- 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
105
|
- `mcp-server/server-http.ts` : wired `ListResourcesRequestSchema` + `ReadResourceRequestSchema` MCP handlers on the existing McpServer instance.
|
|
@@ -102,11 +121,13 @@ Bearer sha256 validation : Already in place since v2.3.4 DCR security fix. `mcp-
|
|
|
102
121
|
|
|
103
122
|
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
123
|
|
|
105
|
-
M3 next : Registry json-render + `__VP_TOOL_RESULT__<json>` stream marker + smoke E2E + ack-checklist + PI-SIGNED Convex prod deploy + visual ack
|
|
124
|
+
M3 next : Registry json-render + `__VP_TOOL_RESULT__<json>` stream marker + smoke E2E + ack-checklist + PI-SIGNED Convex prod deploy + visual ack from beta cohort verifiers.
|
|
125
|
+
|
|
126
|
+
---
|
|
106
127
|
|
|
107
128
|
## v2.3.5 — 2026-05-28
|
|
108
129
|
|
|
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 (
|
|
130
|
+
**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 (2026-05-27) also could not trigger because Zod `.default(50)` / `.default(20)` on `limit` overrode the absent-value signal before it reached the backend.
|
|
110
131
|
|
|
111
132
|
Fixes:
|
|
112
133
|
- `mcp-server/src/tools.ts` : 4 list tools now expose `createdBy` (`list_tasks` + `list_tasks_by_mission` only — `list_missions` + `list_briefing_notes` do not accept it backend-side) and `updatedSince` (all 4).
|
|
@@ -114,15 +135,15 @@ Fixes:
|
|
|
114
135
|
|
|
115
136
|
Tests : 8 new boundary-forwarding cases (`src/__tests__/list-queries-v2.3.5-wire-createdby-updatedsince.test.ts`) — verify MCP layer actually forwards new params to `convex.query` instead of dropping them. 0 regression on existing suites.
|
|
116
137
|
|
|
117
|
-
Detection : Vantage-Bridge architecture review Sigma scope
|
|
138
|
+
Detection : Vantage-Bridge architecture review Sigma scope 2026-05-28 — direct `grep`/`sed` inspection of `tools.ts` confirmed the gap. Backend already correct since v2.3.3 (`convex/tasks.ts:354-357`).
|
|
118
139
|
|
|
119
|
-
Fix-pattern (
|
|
140
|
+
Fix-pattern (2026-05-28 capitalize) : when adding a new param across backend + MCP wrapper, the test suite MUST cover not only schema validation but also the tool-handler→convex.query forwarding boundary. Schema-only tests passed cleanly in v2.3.3 while the actual feature was broken in prod.
|
|
120
141
|
|
|
121
142
|
VP task : `k177tsvdxzase5sjy2qm9fdvp187kbwr`. Predecessor v2.3.3 PR #539 (`k1796s5j6jfkvkx0tn5n926ftd87jx9p`).
|
|
122
143
|
|
|
123
144
|
## v2.3.4 — 2026-05-28
|
|
124
145
|
|
|
125
|
-
**Security fix** — DCR (Dynamic Client Registration) self-registration now defaults to tenant-scope only. Master scope requires explicit admin authorization (`ADMIN_DCR_TOKEN` / `BEARER_SECRET_MASTER` env var). Closes beta blocker for
|
|
146
|
+
**Security fix** — DCR (Dynamic Client Registration) self-registration now defaults to tenant-scope only. Master scope requires explicit admin authorization (`ADMIN_DCR_TOKEN` / `BEARER_SECRET_MASTER` env var). Closes beta blocker for early-access RH cohort onboarding identified in VP Cloud audit 2026-05-28.
|
|
126
147
|
|
|
127
148
|
Changes:
|
|
128
149
|
- `convex/oauth.ts`: `registerPublicClient` now explicitly rejects `scopeProfile="master"` with a `ScopeViolation` error. Previously only the HTTP server enforced this; the Convex-layer was bypassable via direct internal call.
|
|
@@ -131,11 +152,11 @@ Changes:
|
|
|
131
152
|
|
|
132
153
|
Tests: 5 new Convex security tests (`convex/oauth-dcr-security.test.ts`) + 5 new MCP scope enforcement tests (`mcp-server/src/__tests__/dcr-scope-enforcement.test.ts`), 0 regression on existing suites.
|
|
133
154
|
|
|
134
|
-
VP task: k17218rvqyncs1v6rwj3qdzfsn87jj4n. Beta unblock chain: DCR fix → 5 quick wins onboarding (seed-profiles +
|
|
155
|
+
VP task: k17218rvqyncs1v6rwj3qdzfsn87jj4n. Beta unblock chain: DCR fix → 5 quick wins onboarding (seed-profiles + early-access RH cohort client + README VP Cloud + runbook + email).
|
|
135
156
|
|
|
136
157
|
## v2.3.3 — 2026-05-28
|
|
137
158
|
|
|
138
|
-
**Follow-up to v2.3.2 (
|
|
159
|
+
**Follow-up to v2.3.2 (2026-05-28 scope élargi)** — Extend list queries with `createdBy` + `updatedSince` filters + auto-clamp safeguard.
|
|
139
160
|
|
|
140
161
|
Backend (Convex) :
|
|
141
162
|
- `tasks.list` + `tasks.listByMission` : + `createdBy` (filter by task creator) + `updatedSince` (Unix ms window) + auto-clamp limit=30 when `fields="full"` and no explicit limit
|
|
@@ -157,7 +178,7 @@ VP task: `k1796s5j6jfkvkx0tn5n926ftd87jx9p`. Successor of `k17e09ng1tf217n93z9m4
|
|
|
157
178
|
|
|
158
179
|
## v2.3.2 — 2026-05-28
|
|
159
180
|
|
|
160
|
-
**Hotfix** — Expose `fields="lite"` + `status` array/aliases in MCP tool schemas (
|
|
181
|
+
**Hotfix** — Expose `fields="lite"` + `status` array/aliases in MCP tool schemas (2026-05-26 sprint gap).
|
|
161
182
|
|
|
162
183
|
Backend support for these params shipped in v2.3.1 but the MCP wrapper Zod schemas never exposed them, so MCP clients couldn't pass them. Fixed for 4 list tools:
|
|
163
184
|
|
package/dist/server-http.d.ts
CHANGED
|
@@ -24,4 +24,17 @@
|
|
|
24
24
|
* PORT — HTTP port (default 3000)
|
|
25
25
|
* NODE_ENV — set to "production" on Railway
|
|
26
26
|
*/
|
|
27
|
-
|
|
27
|
+
import { Hono } from "hono";
|
|
28
|
+
/**
|
|
29
|
+
* D6 helper — extract client_secret from either the Authorization: Basic header
|
|
30
|
+
* (RFC 6749 §2.3.1 client_secret_basic) or the form body (client_secret_post).
|
|
31
|
+
* Returns { clientId, clientSecret } when present, else nulls.
|
|
32
|
+
*
|
|
33
|
+
* Basic header format: "Basic base64(client_id:client_secret)".
|
|
34
|
+
* Per RFC 6749 §2.3.1 the values are form-urlencoded before being colon-joined.
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseBasicAuthSecret(authHeader: string | undefined, body: Record<string, string>): {
|
|
37
|
+
clientId: string | null;
|
|
38
|
+
clientSecret: string | null;
|
|
39
|
+
};
|
|
40
|
+
export declare const app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|
package/dist/server-http.js
CHANGED
|
@@ -31,6 +31,7 @@ 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
|
+
import { timingSafeEqual } from "./src/crypto.js";
|
|
34
35
|
import { registerTools } from "./src/tools.js";
|
|
35
36
|
import { listUiResources, readUiResource } from "./src/ui-resources/index.js";
|
|
36
37
|
let pkg;
|
|
@@ -86,6 +87,33 @@ function randomOpaqueToken() {
|
|
|
86
87
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
87
88
|
.join("");
|
|
88
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* D6 helper — extract client_secret from either the Authorization: Basic header
|
|
92
|
+
* (RFC 6749 §2.3.1 client_secret_basic) or the form body (client_secret_post).
|
|
93
|
+
* Returns { clientId, clientSecret } when present, else nulls.
|
|
94
|
+
*
|
|
95
|
+
* Basic header format: "Basic base64(client_id:client_secret)".
|
|
96
|
+
* Per RFC 6749 §2.3.1 the values are form-urlencoded before being colon-joined.
|
|
97
|
+
*/
|
|
98
|
+
export function parseBasicAuthSecret(authHeader, body) {
|
|
99
|
+
if (authHeader?.toLowerCase().startsWith("basic ")) {
|
|
100
|
+
try {
|
|
101
|
+
const decoded = atob(authHeader.slice(6).trim());
|
|
102
|
+
const idx = decoded.indexOf(":");
|
|
103
|
+
if (idx > 0) {
|
|
104
|
+
const id = decodeURIComponent(decoded.slice(0, idx));
|
|
105
|
+
const secret = decodeURIComponent(decoded.slice(idx + 1));
|
|
106
|
+
return { clientId: id, clientSecret: secret };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// fall through to body
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const id = typeof body.client_id === "string" ? body.client_id : null;
|
|
114
|
+
const secret = typeof body.client_secret === "string" ? body.client_secret : null;
|
|
115
|
+
return { clientId: id, clientSecret: secret };
|
|
116
|
+
}
|
|
89
117
|
async function loadScopeProfile(profileId) {
|
|
90
118
|
return (await internalClient().query(
|
|
91
119
|
// biome-ignore lint/suspicious/noExplicitAny: Convex string API
|
|
@@ -94,7 +122,7 @@ async function loadScopeProfile(profileId) {
|
|
|
94
122
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
95
123
|
// App
|
|
96
124
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
97
|
-
const app = new Hono();
|
|
125
|
+
export const app = new Hono();
|
|
98
126
|
// CORS — Claude web sends requests from claude.ai origin
|
|
99
127
|
app.use("*", cors({
|
|
100
128
|
origin: "*",
|
|
@@ -191,6 +219,17 @@ app.post("/register", async (c) => {
|
|
|
191
219
|
// obtain master-level access. Non-default profiles are provisioned only
|
|
192
220
|
// via POST /admin/oauth/clients (master-token gated).
|
|
193
221
|
const scopeProfile = DEFAULT_PUBLIC_DCR_PROFILE;
|
|
222
|
+
// RFC 7591 §2: honour token_endpoint_auth_method if provided, else default
|
|
223
|
+
// to client_secret_basic (confidential). Only "none" / "client_secret_basic"
|
|
224
|
+
// / "client_secret_post" are accepted; anything else falls back to default.
|
|
225
|
+
const requestedAuthMethod = typeof body.token_endpoint_auth_method === "string"
|
|
226
|
+
? body.token_endpoint_auth_method
|
|
227
|
+
: undefined;
|
|
228
|
+
const tokenEndpointAuthMethod = requestedAuthMethod === "none" ||
|
|
229
|
+
requestedAuthMethod === "client_secret_basic" ||
|
|
230
|
+
requestedAuthMethod === "client_secret_post"
|
|
231
|
+
? requestedAuthMethod
|
|
232
|
+
: "client_secret_basic";
|
|
194
233
|
try {
|
|
195
234
|
await internalClient().mutation(
|
|
196
235
|
// biome-ignore lint/suspicious/noExplicitAny: Convex string API
|
|
@@ -200,6 +239,7 @@ app.post("/register", async (c) => {
|
|
|
200
239
|
name: clientName,
|
|
201
240
|
redirectUris,
|
|
202
241
|
scopeProfile,
|
|
242
|
+
tokenEndpointAuthMethod,
|
|
203
243
|
});
|
|
204
244
|
}
|
|
205
245
|
catch (err) {
|
|
@@ -214,7 +254,7 @@ app.post("/register", async (c) => {
|
|
|
214
254
|
client_secret_expires_at: 0, // never expires
|
|
215
255
|
redirect_uris: redirectUris,
|
|
216
256
|
client_name: clientName,
|
|
217
|
-
token_endpoint_auth_method:
|
|
257
|
+
token_endpoint_auth_method: tokenEndpointAuthMethod,
|
|
218
258
|
grant_types: ["authorization_code", "refresh_token"],
|
|
219
259
|
response_types: ["code"],
|
|
220
260
|
// SC: standardized on mcp:full — consistent with well-known metadata
|
|
@@ -256,6 +296,16 @@ app.get("/authorize", async (c) => {
|
|
|
256
296
|
if (client.revokedAt !== undefined) {
|
|
257
297
|
return c.json({ error: "invalid_client", error_description: "client revoked" }, 400);
|
|
258
298
|
}
|
|
299
|
+
// D7 — RFC 6749 §3.1.2.3/§3.1.2.4: redirect_uri MUST exact-match a
|
|
300
|
+
// registered URI. Defense against open-redirect / token-exfiltration via
|
|
301
|
+
// attacker-controlled redirect. No partial / prefix / wildcard match.
|
|
302
|
+
const registeredUris = client.redirectUris ?? [];
|
|
303
|
+
if (registeredUris.length === 0 || !registeredUris.includes(redirectUri)) {
|
|
304
|
+
return c.json({
|
|
305
|
+
error: "invalid_request",
|
|
306
|
+
error_description: "redirect_uri does not match a registered redirect URI for this client",
|
|
307
|
+
}, 400);
|
|
308
|
+
}
|
|
259
309
|
const masterTokenForAuthCode = process.env.BEARER_SECRET_MASTER;
|
|
260
310
|
if (!masterTokenForAuthCode) {
|
|
261
311
|
console.error("[oauth] BEARER_SECRET_MASTER not set — cannot mint authorization code");
|
|
@@ -344,6 +394,29 @@ app.post("/token", async (c) => {
|
|
|
344
394
|
if (!client || client.revokedAt !== undefined) {
|
|
345
395
|
return c.json({ error: "invalid_client" }, 400);
|
|
346
396
|
}
|
|
397
|
+
// D6 — RFC 6749 §4.1.3 + §6: confidential clients MUST authenticate at
|
|
398
|
+
// /token. Default (absent) treated as confidential for backward compat.
|
|
399
|
+
// Public clients (token_endpoint_auth_method="none") skip the check —
|
|
400
|
+
// PKCE provides the binding (already verified above).
|
|
401
|
+
const authMethod = client.tokenEndpointAuthMethod ?? "client_secret_basic";
|
|
402
|
+
if (authMethod !== "none") {
|
|
403
|
+
const { clientSecret } = parseBasicAuthSecret(c.req.header("authorization"), body);
|
|
404
|
+
if (!clientSecret) {
|
|
405
|
+
c.header("WWW-Authenticate", 'Basic realm="oauth"');
|
|
406
|
+
return c.json({
|
|
407
|
+
error: "invalid_client",
|
|
408
|
+
error_description: "client authentication required for confidential client",
|
|
409
|
+
}, 401);
|
|
410
|
+
}
|
|
411
|
+
const presentedHash = await sha256Hex(clientSecret);
|
|
412
|
+
if (!client.clientSecretHash ||
|
|
413
|
+
!(await timingSafeEqual(presentedHash, client.clientSecretHash))) {
|
|
414
|
+
return c.json({
|
|
415
|
+
error: "invalid_client",
|
|
416
|
+
error_description: "client_secret mismatch",
|
|
417
|
+
}, 401);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
347
420
|
const profile = await loadScopeProfile(client.scopeProfile);
|
|
348
421
|
if (!profile) {
|
|
349
422
|
console.error("[oauth] scope_profile not found during token issue:", client.scopeProfile);
|
|
@@ -406,6 +479,32 @@ app.post("/token", async (c) => {
|
|
|
406
479
|
if (!record) {
|
|
407
480
|
return c.json({ error: "invalid_grant" }, 400);
|
|
408
481
|
}
|
|
482
|
+
// D6 — confidential client authentication on refresh too (RFC 6749 §6).
|
|
483
|
+
const refreshClient = (await internalClient().query(
|
|
484
|
+
// biome-ignore lint/suspicious/noExplicitAny: Convex string API
|
|
485
|
+
"oauth:getClientByClientId", { clientId: record.clientId }));
|
|
486
|
+
if (!refreshClient || refreshClient.revokedAt !== undefined) {
|
|
487
|
+
return c.json({ error: "invalid_client" }, 400);
|
|
488
|
+
}
|
|
489
|
+
const refreshAuthMethod = refreshClient.tokenEndpointAuthMethod ?? "client_secret_basic";
|
|
490
|
+
if (refreshAuthMethod !== "none") {
|
|
491
|
+
const { clientSecret } = parseBasicAuthSecret(c.req.header("authorization"), body);
|
|
492
|
+
if (!clientSecret) {
|
|
493
|
+
c.header("WWW-Authenticate", 'Basic realm="oauth"');
|
|
494
|
+
return c.json({
|
|
495
|
+
error: "invalid_client",
|
|
496
|
+
error_description: "client authentication required for confidential client",
|
|
497
|
+
}, 401);
|
|
498
|
+
}
|
|
499
|
+
const presentedHash = await sha256Hex(clientSecret);
|
|
500
|
+
if (!refreshClient.clientSecretHash ||
|
|
501
|
+
!(await timingSafeEqual(presentedHash, refreshClient.clientSecretHash))) {
|
|
502
|
+
return c.json({
|
|
503
|
+
error: "invalid_client",
|
|
504
|
+
error_description: "client_secret mismatch",
|
|
505
|
+
}, 401);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
409
508
|
const profile = await loadScopeProfile(record.scopeProfile);
|
|
410
509
|
if (!profile) {
|
|
411
510
|
return c.json({ error: "server_error" }, 500);
|
|
@@ -480,6 +579,14 @@ admin.post("/oauth/clients", async (c) => {
|
|
|
480
579
|
const redirectUris = Array.isArray(body.redirect_uris)
|
|
481
580
|
? body.redirect_uris
|
|
482
581
|
: [];
|
|
582
|
+
const adminRequestedAuthMethod = typeof body.token_endpoint_auth_method === "string"
|
|
583
|
+
? body.token_endpoint_auth_method
|
|
584
|
+
: undefined;
|
|
585
|
+
const adminTokenEndpointAuthMethod = adminRequestedAuthMethod === "none" ||
|
|
586
|
+
adminRequestedAuthMethod === "client_secret_basic" ||
|
|
587
|
+
adminRequestedAuthMethod === "client_secret_post"
|
|
588
|
+
? adminRequestedAuthMethod
|
|
589
|
+
: "client_secret_basic";
|
|
483
590
|
if (!name || !scopeProfile) {
|
|
484
591
|
return c.json({
|
|
485
592
|
error: "invalid_request",
|
|
@@ -503,6 +610,7 @@ admin.post("/oauth/clients", async (c) => {
|
|
|
503
610
|
name,
|
|
504
611
|
redirectUris,
|
|
505
612
|
scopeProfile,
|
|
613
|
+
tokenEndpointAuthMethod: adminTokenEndpointAuthMethod,
|
|
506
614
|
});
|
|
507
615
|
}
|
|
508
616
|
catch (err) {
|
|
@@ -578,16 +686,7 @@ app.all("/mcp", bearerAuthMiddleware(), async (c) => {
|
|
|
578
686
|
// biome-ignore lint/suspicious/noExplicitAny: Convex string API
|
|
579
687
|
return convex.query(functionName, args);
|
|
580
688
|
};
|
|
581
|
-
|
|
582
|
-
return {
|
|
583
|
-
contents: [
|
|
584
|
-
{
|
|
585
|
-
uri: resource.uri,
|
|
586
|
-
mimeType: resource.mimeType,
|
|
587
|
-
text: resource.text,
|
|
588
|
-
},
|
|
589
|
-
],
|
|
590
|
-
};
|
|
689
|
+
return await readUiResource(uri.toString(), fetchConvex);
|
|
591
690
|
});
|
|
592
691
|
const transport = new WebStandardStreamableHTTPServerTransport();
|
|
593
692
|
await server.connect(transport);
|
|
@@ -598,14 +697,18 @@ app.all("/mcp", bearerAuthMiddleware(), async (c) => {
|
|
|
598
697
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
599
698
|
const PORT = Number(process.env.PORT ?? 3000);
|
|
600
699
|
const HOSTNAME = "0.0.0.0";
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
700
|
+
// Bootstrap is gated so tests can `import { app }` without binding a socket.
|
|
701
|
+
// VP_TEST_MODE=1 short-circuits the listener (vitest sets this in setup).
|
|
702
|
+
if (process.env.VP_TEST_MODE !== "1") {
|
|
703
|
+
// Explicit Bun.serve() — does not rely on default-export auto-detection,
|
|
704
|
+
// which can fail when started via `bun run <file>` (vs `bun <file>`).
|
|
705
|
+
// @ts-expect-error — Bun global available at runtime on Railway
|
|
706
|
+
const server = Bun.serve({
|
|
707
|
+
port: PORT,
|
|
708
|
+
hostname: HOSTNAME,
|
|
709
|
+
fetch: app.fetch,
|
|
710
|
+
});
|
|
711
|
+
console.log(`[vantage-peers-mcp] HTTP transport listening on ${server.hostname}:${server.port}`);
|
|
712
|
+
console.log(`[vantage-peers-mcp] Health: http://${server.hostname}:${server.port}/health`);
|
|
713
|
+
console.log(`[vantage-peers-mcp] MCP: http://${server.hostname}:${server.port}/mcp`);
|
|
714
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constant-time comparison helper for hex-encoded hash strings.
|
|
3
|
+
*
|
|
4
|
+
* Ported from `convex/oauth.ts:23-45` (Day 47 master-token gate) to close
|
|
5
|
+
* Eta F1 MAJOR on PR #621 (S1.5 D6+D7): the `presentedHash !== client.clientSecretHash`
|
|
6
|
+
* checks at `mcp-server/server-http.ts` L577-580 (authorization_code grant) and
|
|
7
|
+
* L692 (refresh_token grant) were non-constant-time, leaking a timing oracle on
|
|
8
|
+
* confidential client authentication (RFC 6749 §6).
|
|
9
|
+
*
|
|
10
|
+
* Algorithm is **identical** to the Convex helper:
|
|
11
|
+
* 1. TextEncoder → bytes.
|
|
12
|
+
* 2. Length mismatch → still run a dummy HMAC over equal-length input to
|
|
13
|
+
* avoid a branch-timing leak, then return false.
|
|
14
|
+
* 3. Equal length → XOR-accumulate diff, return diff === 0.
|
|
15
|
+
*
|
|
16
|
+
* Web Crypto (`crypto.subtle`) is used (not the Node `crypto.timingSafeEqual`
|
|
17
|
+
* variant) for two reasons:
|
|
18
|
+
* - Parity with the Convex implementation — same algorithm, same surface.
|
|
19
|
+
* - sha256Hex outputs are 64-char hex strings so length is normally equal,
|
|
20
|
+
* but defensively we handle mismatch (e.g. empty string fallback when
|
|
21
|
+
* `clientSecretHash` is undefined on a malformed row).
|
|
22
|
+
*/
|
|
23
|
+
export declare function timingSafeEqual(a: string, b: string): Promise<boolean>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constant-time comparison helper for hex-encoded hash strings.
|
|
3
|
+
*
|
|
4
|
+
* Ported from `convex/oauth.ts:23-45` (Day 47 master-token gate) to close
|
|
5
|
+
* Eta F1 MAJOR on PR #621 (S1.5 D6+D7): the `presentedHash !== client.clientSecretHash`
|
|
6
|
+
* checks at `mcp-server/server-http.ts` L577-580 (authorization_code grant) and
|
|
7
|
+
* L692 (refresh_token grant) were non-constant-time, leaking a timing oracle on
|
|
8
|
+
* confidential client authentication (RFC 6749 §6).
|
|
9
|
+
*
|
|
10
|
+
* Algorithm is **identical** to the Convex helper:
|
|
11
|
+
* 1. TextEncoder → bytes.
|
|
12
|
+
* 2. Length mismatch → still run a dummy HMAC over equal-length input to
|
|
13
|
+
* avoid a branch-timing leak, then return false.
|
|
14
|
+
* 3. Equal length → XOR-accumulate diff, return diff === 0.
|
|
15
|
+
*
|
|
16
|
+
* Web Crypto (`crypto.subtle`) is used (not the Node `crypto.timingSafeEqual`
|
|
17
|
+
* variant) for two reasons:
|
|
18
|
+
* - Parity with the Convex implementation — same algorithm, same surface.
|
|
19
|
+
* - sha256Hex outputs are 64-char hex strings so length is normally equal,
|
|
20
|
+
* but defensively we handle mismatch (e.g. empty string fallback when
|
|
21
|
+
* `clientSecretHash` is undefined on a malformed row).
|
|
22
|
+
*/
|
|
23
|
+
export async function timingSafeEqual(a, b) {
|
|
24
|
+
const encoder = new TextEncoder();
|
|
25
|
+
const aBytes = encoder.encode(a);
|
|
26
|
+
const bBytes = encoder.encode(b);
|
|
27
|
+
if (aBytes.length !== bBytes.length) {
|
|
28
|
+
// Still do a comparison on equal-length buffers to avoid branch-timing leak.
|
|
29
|
+
const dummy = new Uint8Array(aBytes.length);
|
|
30
|
+
const aKey = await crypto.subtle.importKey("raw", aBytes.length > 0 ? aBytes : new Uint8Array([0]), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
31
|
+
await crypto.subtle.sign("HMAC", aKey, dummy);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
let diff = 0;
|
|
35
|
+
for (let i = 0; i < aBytes.length; i++) {
|
|
36
|
+
diff |= aBytes[i] ^ bBytes[i];
|
|
37
|
+
}
|
|
38
|
+
return diff === 0;
|
|
39
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprint S3.1 B2 — scope-aware filter helpers for VP MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the binary `guardMasterOnly` 403 rejection with row-level filtering
|
|
5
|
+
* so non-master scoped OAuth clients (Marie's onboarding case 2026-06-01) can
|
|
6
|
+
* see their own data instead of receiving a blanket Forbidden.
|
|
7
|
+
*
|
|
8
|
+
* Doctrine references:
|
|
9
|
+
* - decisions/doctrine-scope-aware-filter-2026-05-26.md (D3 base)
|
|
10
|
+
* - memory j579y6f31g7xzgtgdnpgetdmjx87ztyj (D9-D14 extension)
|
|
11
|
+
*
|
|
12
|
+
* Contract:
|
|
13
|
+
* - Master scope (isMasterScope === true) = wildcard pass.
|
|
14
|
+
* - Legacy bearer (oauthCtx === undefined) = wildcard pass (treated as master-
|
|
15
|
+
* equivalent for backward-compatibility with mcpTenants Pi/Tau/Phi paths).
|
|
16
|
+
* - Non-master scope = row passes iff:
|
|
17
|
+
* row.createdBy ∈ oauthCtx.fromAllowList
|
|
18
|
+
* OR
|
|
19
|
+
* row.namespace startsWith one of oauthCtx.namespaceReadPrefixes
|
|
20
|
+
* (prefix matched as exact-equal OR followed by '/' boundary)
|
|
21
|
+
* - Row missing BOTH `createdBy` and `namespace` = denied for non-master.
|
|
22
|
+
*
|
|
23
|
+
* NOTE: this module is intentionally framework-agnostic — no McpServer / Hono
|
|
24
|
+
* imports — to keep the helpers trivially unit-testable.
|
|
25
|
+
*/
|
|
26
|
+
import { type OAuthContext } from "./auth.js";
|
|
27
|
+
/**
|
|
28
|
+
* Row shape accepted by the scope filter. All fields optional because real
|
|
29
|
+
* Convex documents from list_peers / list_messages / etc. don't all carry both.
|
|
30
|
+
*/
|
|
31
|
+
export type ScopeFilterable = {
|
|
32
|
+
createdBy?: string;
|
|
33
|
+
namespace?: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Core predicate. Returns true when the row is visible to the caller.
|
|
37
|
+
*
|
|
38
|
+
* - Master scope (isMasterScope === true) → true (wildcard)
|
|
39
|
+
* - Legacy bearer (oauthCtx === undefined) → true (back-compat)
|
|
40
|
+
* - row.createdBy ∈ oauthCtx.fromAllowList → true
|
|
41
|
+
* - row.namespace === prefix OR startsWith prefix+'/'
|
|
42
|
+
* for any prefix ∈ oauthCtx.namespaceReadPrefixes → true
|
|
43
|
+
* - otherwise → false
|
|
44
|
+
*
|
|
45
|
+
* Substring matches that don't fall on a '/' boundary are explicitly rejected
|
|
46
|
+
* (e.g. namespace="orchestrator/alphabet" does NOT match prefix
|
|
47
|
+
* "orchestrator/alpha"). This avoids the classic prefix-isolation bypass.
|
|
48
|
+
*/
|
|
49
|
+
export declare function passesScopeFilter(oauthCtx: OAuthContext | undefined, row: ScopeFilterable): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Filter a list of rows (post-query). Used by list_* tools.
|
|
52
|
+
*/
|
|
53
|
+
export declare function scopeFilterList<T extends ScopeFilterable>(oauthCtx: OAuthContext | undefined, rows: T[]): T[];
|
|
54
|
+
/**
|
|
55
|
+
* Assert a single row passes (get_* tools). Returns the row when allowed, null
|
|
56
|
+
* otherwise — callers translate null to a 404-equivalent "not found" MCP error
|
|
57
|
+
* to avoid leaking the difference between "absent" and "filtered out".
|
|
58
|
+
*/
|
|
59
|
+
export declare function scopeFilterGet<T extends ScopeFilterable>(oauthCtx: OAuthContext | undefined, row: T | null | undefined): T | null;
|
|
60
|
+
/**
|
|
61
|
+
* Helper for callers that want a single "is master / legacy" predicate without
|
|
62
|
+
* importing isMasterScope directly. Mirrors the legacy-bearer-passes-through
|
|
63
|
+
* convention.
|
|
64
|
+
*/
|
|
65
|
+
export declare function isWildcardScope(ctx: OAuthContext | undefined): boolean;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprint S3.1 B2 — scope-aware filter helpers for VP MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the binary `guardMasterOnly` 403 rejection with row-level filtering
|
|
5
|
+
* so non-master scoped OAuth clients (Marie's onboarding case 2026-06-01) can
|
|
6
|
+
* see their own data instead of receiving a blanket Forbidden.
|
|
7
|
+
*
|
|
8
|
+
* Doctrine references:
|
|
9
|
+
* - decisions/doctrine-scope-aware-filter-2026-05-26.md (D3 base)
|
|
10
|
+
* - memory j579y6f31g7xzgtgdnpgetdmjx87ztyj (D9-D14 extension)
|
|
11
|
+
*
|
|
12
|
+
* Contract:
|
|
13
|
+
* - Master scope (isMasterScope === true) = wildcard pass.
|
|
14
|
+
* - Legacy bearer (oauthCtx === undefined) = wildcard pass (treated as master-
|
|
15
|
+
* equivalent for backward-compatibility with mcpTenants Pi/Tau/Phi paths).
|
|
16
|
+
* - Non-master scope = row passes iff:
|
|
17
|
+
* row.createdBy ∈ oauthCtx.fromAllowList
|
|
18
|
+
* OR
|
|
19
|
+
* row.namespace startsWith one of oauthCtx.namespaceReadPrefixes
|
|
20
|
+
* (prefix matched as exact-equal OR followed by '/' boundary)
|
|
21
|
+
* - Row missing BOTH `createdBy` and `namespace` = denied for non-master.
|
|
22
|
+
*
|
|
23
|
+
* NOTE: this module is intentionally framework-agnostic — no McpServer / Hono
|
|
24
|
+
* imports — to keep the helpers trivially unit-testable.
|
|
25
|
+
*/
|
|
26
|
+
import { isMasterScope } from "./auth.js";
|
|
27
|
+
/**
|
|
28
|
+
* Core predicate. Returns true when the row is visible to the caller.
|
|
29
|
+
*
|
|
30
|
+
* - Master scope (isMasterScope === true) → true (wildcard)
|
|
31
|
+
* - Legacy bearer (oauthCtx === undefined) → true (back-compat)
|
|
32
|
+
* - row.createdBy ∈ oauthCtx.fromAllowList → true
|
|
33
|
+
* - row.namespace === prefix OR startsWith prefix+'/'
|
|
34
|
+
* for any prefix ∈ oauthCtx.namespaceReadPrefixes → true
|
|
35
|
+
* - otherwise → false
|
|
36
|
+
*
|
|
37
|
+
* Substring matches that don't fall on a '/' boundary are explicitly rejected
|
|
38
|
+
* (e.g. namespace="orchestrator/alphabet" does NOT match prefix
|
|
39
|
+
* "orchestrator/alpha"). This avoids the classic prefix-isolation bypass.
|
|
40
|
+
*/
|
|
41
|
+
export function passesScopeFilter(oauthCtx, row) {
|
|
42
|
+
if (!oauthCtx)
|
|
43
|
+
return true;
|
|
44
|
+
if (isMasterScope(oauthCtx))
|
|
45
|
+
return true;
|
|
46
|
+
const { createdBy, namespace } = row;
|
|
47
|
+
if (createdBy && oauthCtx.fromAllowList.includes(createdBy))
|
|
48
|
+
return true;
|
|
49
|
+
if (namespace) {
|
|
50
|
+
for (const p of oauthCtx.namespaceReadPrefixes) {
|
|
51
|
+
if (namespace === p)
|
|
52
|
+
return true;
|
|
53
|
+
if (namespace.startsWith(`${p}/`))
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Filter a list of rows (post-query). Used by list_* tools.
|
|
61
|
+
*/
|
|
62
|
+
export function scopeFilterList(oauthCtx, rows) {
|
|
63
|
+
return rows.filter((r) => passesScopeFilter(oauthCtx, r));
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Assert a single row passes (get_* tools). Returns the row when allowed, null
|
|
67
|
+
* otherwise — callers translate null to a 404-equivalent "not found" MCP error
|
|
68
|
+
* to avoid leaking the difference between "absent" and "filtered out".
|
|
69
|
+
*/
|
|
70
|
+
export function scopeFilterGet(oauthCtx, row) {
|
|
71
|
+
if (row == null)
|
|
72
|
+
return null;
|
|
73
|
+
return passesScopeFilter(oauthCtx, row) ? row : null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Helper for callers that want a single "is master / legacy" predicate without
|
|
77
|
+
* importing isMasterScope directly. Mirrors the legacy-bearer-passes-through
|
|
78
|
+
* convention.
|
|
79
|
+
*/
|
|
80
|
+
export function isWildcardScope(ctx) {
|
|
81
|
+
if (!ctx)
|
|
82
|
+
return true;
|
|
83
|
+
return isMasterScope(ctx);
|
|
84
|
+
}
|
package/dist/src/tools.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
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 { scopeFilterGet, scopeFilterList } from "./scope-filter.js";
|
|
13
14
|
import { wrapToolResult } from "./ui-resources/stream-marker.js";
|
|
14
15
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
16
|
// VP_EMIT_UI_MARKERS gate
|
|
@@ -582,14 +583,18 @@ export function registerTools(server, convex, oauthCtx) {
|
|
|
582
583
|
title: "Get memory",
|
|
583
584
|
}, async ({ memoryId }) => {
|
|
584
585
|
try {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
586
|
+
// S3.1.A Wave A — scope-aware filter replaces guardMasterOnly.
|
|
587
|
+
// Non-master clients may now read their own data; cross-tenant rows
|
|
588
|
+
// collapse to a non-leaky "not found" shape (same as a missing row).
|
|
588
589
|
const memory = await convex.query("memories:getMemory", {
|
|
589
590
|
memoryId,
|
|
590
591
|
});
|
|
592
|
+
const filtered = scopeFilterGet(oauthCtx, memory);
|
|
593
|
+
if (filtered === null) {
|
|
594
|
+
return mcpError(`Memory not found: ${memoryId}`);
|
|
595
|
+
}
|
|
591
596
|
return {
|
|
592
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
597
|
+
content: [{ type: "text", text: JSON.stringify(filtered, null, 2) }],
|
|
593
598
|
};
|
|
594
599
|
}
|
|
595
600
|
catch (error) {
|
|
@@ -937,10 +942,20 @@ export function registerTools(server, convex, oauthCtx) {
|
|
|
937
942
|
: Array.isArray(memories?.page)
|
|
938
943
|
? memories.page
|
|
939
944
|
: [];
|
|
940
|
-
|
|
945
|
+
// S3.1.A Wave A — row-level scope filter on the post-query list.
|
|
946
|
+
// Master + legacy bearer pass through unchanged. Non-master clients
|
|
947
|
+
// see only rows whose createdBy ∈ fromAllowList OR whose namespace
|
|
948
|
+
// matches one of namespaceReadPrefixes (exact or '/' boundary).
|
|
949
|
+
const filteredList = scopeFilterList(oauthCtx, rawList);
|
|
950
|
+
// Preserve the original response shape (array vs {page} envelope)
|
|
951
|
+
// so downstream consumers don't need to special-case Wave A.
|
|
952
|
+
const filteredEnvelope = Array.isArray(memories)
|
|
953
|
+
? filteredList
|
|
954
|
+
: { ...memories, page: filteredList };
|
|
955
|
+
const baseText = capListResponseBytes(filteredEnvelope, JSON.stringify(filteredEnvelope, null, 2), "list_memories");
|
|
941
956
|
const text = appendMarkerIfEnabled(baseText, () => ({
|
|
942
957
|
kind: "memory-quote",
|
|
943
|
-
items:
|
|
958
|
+
items: filteredList.map((m) => ({
|
|
944
959
|
_id: m._id,
|
|
945
960
|
namespace: m.namespace,
|
|
946
961
|
type: m.type,
|
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SEP-1865 ui:// resources for VantagePeers Generative UI.
|
|
3
3
|
*
|
|
4
|
+
* Canonical PR #1865 (MERGED 2026-01-28) compliance:
|
|
5
|
+
* - MIME: text/html;profile=mcp-app (RESOURCE_MIME_TYPE)
|
|
6
|
+
* - _meta.ui: UIResourceMeta envelope (nested, NOT flat _meta["ui/resourceUri"])
|
|
7
|
+
* - Capability key declared at server initialize: io.modelcontextprotocol/ui
|
|
8
|
+
* - Fallback markdown content item in resources/read response (Critical Rule #1)
|
|
9
|
+
*
|
|
10
|
+
* Uses @mcp-ui/server createUIResource() helper (reference impl by SEP-1865 co-author).
|
|
11
|
+
*
|
|
4
12
|
* URI pattern : ui://vp/v1/<primitive>?<query>
|
|
5
13
|
* Examples :
|
|
6
14
|
* ui://vp/v1/tasks-table?assignedTo=pi&status=review&fields=lite&limit=10
|
|
7
15
|
* ui://vp/v1/messages-feed?recipient=sigma&limit=20
|
|
8
16
|
*
|
|
9
|
-
* M1 scope : 1 primitive (tasks-table) — proves the pipeline.
|
|
10
|
-
* M2 scope : ≥6 primitives (tasks/messages/diary/missions/briefingNotes/memories).
|
|
11
|
-
*
|
|
12
17
|
* 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
18
|
*
|
|
15
19
|
* Reference instance Theta : theta-vantage-crm-gui-iframe-embed-v1 (blissful-gopher-531).
|
|
16
20
|
* Mission Sigma : sigma-vantage-peers-mcp-gui-iframe-embed-v1 (k5730xct6rvrwkvxhy5t5js12d87jwfw).
|
|
17
21
|
*/
|
|
22
|
+
export declare const MCP_UI_CAPABILITY_KEY: "io.modelcontextprotocol/ui";
|
|
23
|
+
export declare const MCP_UI_MIME_TYPE = "text/html;profile=mcp-app";
|
|
24
|
+
type UIResourceMeta = Record<string, never>;
|
|
18
25
|
export type UiResourceParsed = {
|
|
19
26
|
primitive: string;
|
|
20
27
|
query: URLSearchParams;
|
|
@@ -23,14 +30,30 @@ export declare function parseUiUri(uri: string): UiResourceParsed | null;
|
|
|
23
30
|
export declare const PRIMITIVES: readonly ["tasks-table", "messages-feed", "diary-entry", "mission-timeline", "briefing-note", "memory-quote"];
|
|
24
31
|
export type Primitive = (typeof PRIMITIVES)[number];
|
|
25
32
|
export declare const PRIMITIVE_DESCRIPTIONS: Record<Primitive, string>;
|
|
26
|
-
export
|
|
33
|
+
export type UiResourceListEntry = {
|
|
27
34
|
uri: string;
|
|
28
35
|
name: string;
|
|
29
36
|
description: string;
|
|
30
37
|
mimeType: string;
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
_meta: {
|
|
39
|
+
ui: UIResourceMeta;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export declare function listUiResources(): UiResourceListEntry[];
|
|
43
|
+
export type UiResourceContent = {
|
|
33
44
|
uri: string;
|
|
34
45
|
mimeType: string;
|
|
35
46
|
text: string;
|
|
36
|
-
|
|
47
|
+
_meta?: {
|
|
48
|
+
ui: UIResourceMeta;
|
|
49
|
+
};
|
|
50
|
+
} | {
|
|
51
|
+
uri: string;
|
|
52
|
+
mimeType: "text/markdown";
|
|
53
|
+
text: string;
|
|
54
|
+
};
|
|
55
|
+
export type UiResourceReadResult = {
|
|
56
|
+
contents: UiResourceContent[];
|
|
57
|
+
};
|
|
58
|
+
export declare function readUiResource(uri: string, fetchConvex: (functionName: string, args: Record<string, unknown>) => Promise<unknown>): Promise<UiResourceReadResult>;
|
|
59
|
+
export {};
|
|
@@ -1,26 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SEP-1865 ui:// resources for VantagePeers Generative UI.
|
|
3
3
|
*
|
|
4
|
+
* Canonical PR #1865 (MERGED 2026-01-28) compliance:
|
|
5
|
+
* - MIME: text/html;profile=mcp-app (RESOURCE_MIME_TYPE)
|
|
6
|
+
* - _meta.ui: UIResourceMeta envelope (nested, NOT flat _meta["ui/resourceUri"])
|
|
7
|
+
* - Capability key declared at server initialize: io.modelcontextprotocol/ui
|
|
8
|
+
* - Fallback markdown content item in resources/read response (Critical Rule #1)
|
|
9
|
+
*
|
|
10
|
+
* Uses @mcp-ui/server createUIResource() helper (reference impl by SEP-1865 co-author).
|
|
11
|
+
*
|
|
4
12
|
* URI pattern : ui://vp/v1/<primitive>?<query>
|
|
5
13
|
* Examples :
|
|
6
14
|
* ui://vp/v1/tasks-table?assignedTo=pi&status=review&fields=lite&limit=10
|
|
7
15
|
* ui://vp/v1/messages-feed?recipient=sigma&limit=20
|
|
8
16
|
*
|
|
9
|
-
* M1 scope : 1 primitive (tasks-table) — proves the pipeline.
|
|
10
|
-
* M2 scope : ≥6 primitives (tasks/messages/diary/missions/briefingNotes/memories).
|
|
11
|
-
*
|
|
12
17
|
* 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
18
|
*
|
|
15
19
|
* Reference instance Theta : theta-vantage-crm-gui-iframe-embed-v1 (blissful-gopher-531).
|
|
16
20
|
* Mission Sigma : sigma-vantage-peers-mcp-gui-iframe-embed-v1 (k5730xct6rvrwkvxhy5t5js12d87jwfw).
|
|
17
21
|
*/
|
|
22
|
+
import { RESOURCE_MIME_TYPE } from "@mcp-ui/server";
|
|
18
23
|
import { renderBriefingNote } from "./primitives/briefing-note.js";
|
|
19
24
|
import { renderDiaryEntry } from "./primitives/diary-entry.js";
|
|
20
25
|
import { renderMemoryQuote } from "./primitives/memory-quote.js";
|
|
21
26
|
import { renderMessagesFeed } from "./primitives/messages-feed.js";
|
|
22
27
|
import { renderMissionTimeline } from "./primitives/mission-timeline.js";
|
|
23
28
|
import { renderTasksTable } from "./primitives/tasks-table.js";
|
|
29
|
+
// MCP Apps capability negotiation key (declared at server initialize handshake).
|
|
30
|
+
// Tools opting into UI resources reference it from their _meta.ui field.
|
|
31
|
+
export const MCP_UI_CAPABILITY_KEY = "io.modelcontextprotocol/ui";
|
|
32
|
+
// PR #1865 MIME — re-export from @mcp-ui/server for downstream visibility.
|
|
33
|
+
export const MCP_UI_MIME_TYPE = RESOURCE_MIME_TYPE;
|
|
34
|
+
const DEFAULT_UI_META = {};
|
|
24
35
|
// URI parser : ui://vp/v1/<primitive>?<query>
|
|
25
36
|
const UI_URI_RE = /^ui:\/\/vp\/v1\/([a-z][a-z0-9-]*)(?:\?(.*))?$/;
|
|
26
37
|
export function parseUiUri(uri) {
|
|
@@ -51,16 +62,36 @@ export const PRIMITIVE_DESCRIPTIONS = {
|
|
|
51
62
|
"briefing-note": "Render briefing note details. Query params: noteId or (topic + limit), lang.",
|
|
52
63
|
"memory-quote": "Render memory quotes from a namespace. Query params: namespace, type, limit, lang.",
|
|
53
64
|
};
|
|
54
|
-
// Resource list — returned by resources/list MCP handler
|
|
65
|
+
// Resource list — returned by resources/list MCP handler.
|
|
66
|
+
// PR #1865 canonical: mimeType=text/html;profile=mcp-app + _meta.ui envelope.
|
|
55
67
|
export function listUiResources() {
|
|
56
68
|
return PRIMITIVES.map((p) => ({
|
|
57
69
|
uri: `ui://vp/v1/${p}`,
|
|
58
70
|
name: p,
|
|
59
71
|
description: PRIMITIVE_DESCRIPTIONS[p],
|
|
60
|
-
mimeType:
|
|
72
|
+
mimeType: MCP_UI_MIME_TYPE,
|
|
73
|
+
_meta: { ui: DEFAULT_UI_META },
|
|
61
74
|
}));
|
|
62
75
|
}
|
|
63
|
-
//
|
|
76
|
+
// Markdown fallback per Critical Rule #1: every UI resource MUST provide a
|
|
77
|
+
// meaningful text-only payload for hosts without the UI extension. We render a
|
|
78
|
+
// short hint + the primitive description so model + non-UI clients still get
|
|
79
|
+
// usable output (raw HTML is not a substitute — it is the same content the iframe
|
|
80
|
+
// would render, defeating the fallback purpose).
|
|
81
|
+
function renderMarkdownFallback(uri, primitive) {
|
|
82
|
+
const desc = PRIMITIVE_DESCRIPTIONS[primitive];
|
|
83
|
+
return [
|
|
84
|
+
`# ${primitive}`,
|
|
85
|
+
"",
|
|
86
|
+
`This resource (${uri}) provides an interactive UI rendering for VantagePeers \`${primitive}\` data.`,
|
|
87
|
+
"",
|
|
88
|
+
desc,
|
|
89
|
+
"",
|
|
90
|
+
"Your client does not appear to support the MCP UI extension (`text/html;profile=mcp-app`). For a textual view of the same data, call the corresponding VantagePeers tool directly (e.g. `list_tasks`, `list_messages`, `list_diaries`, `list_missions`, `list_briefing_notes`, `recall`) with equivalent filters.",
|
|
91
|
+
].join("\n");
|
|
92
|
+
}
|
|
93
|
+
// Resource read — dispatched by primitive name. Returns canonical
|
|
94
|
+
// resources/read contents array: [HTML profile=mcp-app, markdown fallback].
|
|
64
95
|
export async function readUiResource(uri, fetchConvex) {
|
|
65
96
|
const parsed = parseUiUri(uri);
|
|
66
97
|
if (!parsed) {
|
|
@@ -69,8 +100,9 @@ export async function readUiResource(uri, fetchConvex) {
|
|
|
69
100
|
if (!PRIMITIVES.includes(parsed.primitive)) {
|
|
70
101
|
throw new Error(`[VP UI Resources] Unknown primitive: ${parsed.primitive}. Available: ${PRIMITIVES.join(", ")}`);
|
|
71
102
|
}
|
|
103
|
+
const primitive = parsed.primitive;
|
|
72
104
|
let html;
|
|
73
|
-
switch (
|
|
105
|
+
switch (primitive) {
|
|
74
106
|
case "tasks-table":
|
|
75
107
|
html = await renderTasksTable(parsed.query, fetchConvex);
|
|
76
108
|
break;
|
|
@@ -93,8 +125,18 @@ export async function readUiResource(uri, fetchConvex) {
|
|
|
93
125
|
throw new Error(`[VP UI Resources] Unimplemented primitive: ${parsed.primitive}`);
|
|
94
126
|
}
|
|
95
127
|
return {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
128
|
+
contents: [
|
|
129
|
+
{
|
|
130
|
+
uri,
|
|
131
|
+
mimeType: MCP_UI_MIME_TYPE,
|
|
132
|
+
text: html,
|
|
133
|
+
_meta: { ui: DEFAULT_UI_META },
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
uri,
|
|
137
|
+
mimeType: "text/markdown",
|
|
138
|
+
text: renderMarkdownFallback(uri, primitive),
|
|
139
|
+
},
|
|
140
|
+
],
|
|
99
141
|
};
|
|
100
142
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vantage-peers-mcp",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.14",
|
|
4
4
|
"description": "MCP server for VantagePeers — shared memory, messaging, and task coordination for AI agent teams",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/server.js",
|
|
@@ -25,7 +25,10 @@
|
|
|
25
25
|
"prepublishOnly": "npm run build",
|
|
26
26
|
"dev": "bun run server.ts",
|
|
27
27
|
"start": "bun run dist/server-http.js",
|
|
28
|
-
"dev:http": "bun run server-http.ts"
|
|
28
|
+
"dev:http": "bun run server-http.ts",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"test:coverage": "vitest run --coverage"
|
|
29
32
|
},
|
|
30
33
|
"repository": {
|
|
31
34
|
"type": "git",
|
|
@@ -54,11 +57,18 @@
|
|
|
54
57
|
"rag",
|
|
55
58
|
"vector-search"
|
|
56
59
|
],
|
|
57
|
-
"author": "
|
|
60
|
+
"author": "VantageOS AI Orchestrator Team",
|
|
61
|
+
"contributors": [
|
|
62
|
+
"Pi (π) — Lead orchestrator",
|
|
63
|
+
"Laurent Perello — Founder, supervisor",
|
|
64
|
+
"ElPi Corp"
|
|
65
|
+
],
|
|
58
66
|
"license": "FSL-1.1-Apache-2.0",
|
|
59
67
|
"dependencies": {
|
|
68
|
+
"@mcp-ui/server": "^6.1.0",
|
|
60
69
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
61
|
-
"
|
|
70
|
+
"@vantageos/mosaic": "^0.1.2",
|
|
71
|
+
"convex": ">=1.0.0",
|
|
62
72
|
"dotenv": "^17.4.2",
|
|
63
73
|
"zod": "^4.3.6"
|
|
64
74
|
},
|
|
@@ -66,14 +76,16 @@
|
|
|
66
76
|
"convex": ">=1.0.0"
|
|
67
77
|
},
|
|
68
78
|
"devDependencies": {
|
|
79
|
+
"@types/node": "^24.12.2",
|
|
80
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
69
81
|
"typescript": "^5.9.3",
|
|
70
|
-
"
|
|
82
|
+
"vitest": "^4.1.8"
|
|
71
83
|
},
|
|
72
84
|
"overrides": {
|
|
73
85
|
"path-to-regexp": "^8.4.0"
|
|
74
86
|
},
|
|
75
87
|
"engines": {
|
|
76
|
-
"node": ">=
|
|
88
|
+
"node": ">=20"
|
|
77
89
|
},
|
|
78
90
|
"publishConfig": {
|
|
79
91
|
"access": "public",
|