vantage-peers-mcp 2.4.13 → 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.
@@ -24,4 +24,17 @@
24
24
  * PORT — HTTP port (default 3000)
25
25
  * NODE_ENV — set to "production" on Railway
26
26
  */
27
- export {};
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, "/">;
@@ -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: "client_secret_post",
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) {
@@ -589,14 +697,18 @@ app.all("/mcp", bearerAuthMiddleware(), async (c) => {
589
697
  // ─────────────────────────────────────────────────────────────────────────────
590
698
  const PORT = Number(process.env.PORT ?? 3000);
591
699
  const HOSTNAME = "0.0.0.0";
592
- // Explicit Bun.serve() does not rely on default-export auto-detection,
593
- // which can fail when started via `bun run <file>` (vs `bun <file>`).
594
- // @ts-expect-error Bun global available at runtime on Railway
595
- const server = Bun.serve({
596
- port: PORT,
597
- hostname: HOSTNAME,
598
- fetch: app.fetch,
599
- });
600
- console.log(`[vantage-peers-mcp] HTTP transport listening on ${server.hostname}:${server.port}`);
601
- console.log(`[vantage-peers-mcp] Health: http://${server.hostname}:${server.port}/health`);
602
- console.log(`[vantage-peers-mcp] MCP: http://${server.hostname}:${server.port}/mcp`);
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
- const _scopeDenied = guardMasterOnly("get_memory");
586
- if (_scopeDenied)
587
- return _scopeDenied;
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(memory, null, 2) }],
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
- const baseText = capListResponseBytes(memories, JSON.stringify(memories, null, 2), "list_memories");
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: rawList.map((m) => ({
958
+ items: filteredList.map((m) => ({
944
959
  _id: m._id,
945
960
  namespace: m.namespace,
946
961
  type: m.type,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vantage-peers-mcp",
3
- "version": "2.4.13",
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",
@@ -74,7 +77,9 @@
74
77
  },
75
78
  "devDependencies": {
76
79
  "@types/node": "^24.12.2",
77
- "typescript": "^5.9.3"
80
+ "@vitest/coverage-v8": "^4.1.8",
81
+ "typescript": "^5.9.3",
82
+ "vitest": "^4.1.8"
78
83
  },
79
84
  "overrides": {
80
85
  "path-to-regexp": "^8.4.0"