vantage-peers-mcp 2.0.2 → 2.1.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.
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * VantagePeers MCP Server — HTTP Transport (Railway deploy)
4
+ *
5
+ * Wraps the same 82 tool definitions as the stdio server (server.ts) but
6
+ * serves them over Streamable HTTP for Claude web clients.
7
+ *
8
+ * Architecture:
9
+ * - One Railway instance, many tenants / OAuth clients
10
+ * - Each /mcp request authenticated via bearer token → either:
11
+ * · master bearer (admin shortcut, scopeProfile=master)
12
+ * · OAuth access_token (scoped, persisted in oauth_access_tokens)
13
+ * · legacy mcpTenants bearer (internal orchestrators on their own deployment)
14
+ * - Per-request ConvexHttpClient pointed at the resolved deployment
15
+ * - Stateless mode: fresh McpServer + transport per request (no session state)
16
+ *
17
+ * OAuth state (clients, codes, access/refresh tokens, scope profiles) is
18
+ * persisted in Convex (see convex/oauth.ts) — no more in-memory Maps.
19
+ *
20
+ * ENV VARS (see README.md "HTTP deploy" section):
21
+ * CONVEX_URL_INTERNAL — internal VantagePeers Convex URL
22
+ * BEARER_SECRET_MASTER — master admin token
23
+ * PUBLIC_BASE_URL — public URL of this server (for OAuth discovery)
24
+ * PORT — HTTP port (default 3000)
25
+ * NODE_ENV — set to "production" on Railway
26
+ */
27
+ export {};
@@ -0,0 +1,515 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * VantagePeers MCP Server — HTTP Transport (Railway deploy)
4
+ *
5
+ * Wraps the same 82 tool definitions as the stdio server (server.ts) but
6
+ * serves them over Streamable HTTP for Claude web clients.
7
+ *
8
+ * Architecture:
9
+ * - One Railway instance, many tenants / OAuth clients
10
+ * - Each /mcp request authenticated via bearer token → either:
11
+ * · master bearer (admin shortcut, scopeProfile=master)
12
+ * · OAuth access_token (scoped, persisted in oauth_access_tokens)
13
+ * · legacy mcpTenants bearer (internal orchestrators on their own deployment)
14
+ * - Per-request ConvexHttpClient pointed at the resolved deployment
15
+ * - Stateless mode: fresh McpServer + transport per request (no session state)
16
+ *
17
+ * OAuth state (clients, codes, access/refresh tokens, scope profiles) is
18
+ * persisted in Convex (see convex/oauth.ts) — no more in-memory Maps.
19
+ *
20
+ * ENV VARS (see README.md "HTTP deploy" section):
21
+ * CONVEX_URL_INTERNAL — internal VantagePeers Convex URL
22
+ * BEARER_SECRET_MASTER — master admin token
23
+ * PUBLIC_BASE_URL — public URL of this server (for OAuth discovery)
24
+ * PORT — HTTP port (default 3000)
25
+ * NODE_ENV — set to "production" on Railway
26
+ */
27
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
28
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
29
+ import { ConvexHttpClient } from "convex/browser";
30
+ import { Hono } from "hono";
31
+ import { cors } from "hono/cors";
32
+ import { bearerAuthMiddleware, internalClient, masterOnlyMiddleware, sha256Base64Url, sha256Hex, } from "./src/auth.js";
33
+ import { registerTools } from "./src/tools.js";
34
+ // ─────────────────────────────────────────────────────────────────────────────
35
+ // Constants
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+ const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL ??
38
+ "https://vantage-peers-production.up.railway.app";
39
+ const ACCESS_TOKEN_TTL_SECONDS = 3600; // 1 hour
40
+ const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 3600; // 30 days
41
+ const AUTH_CODE_TTL_SECONDS = 600; // 10 minutes
42
+ // Default profile for anonymous DCR (Claude.ai connector without pre-provisioning).
43
+ // Deny-by-default; Pi must manually elevate a client post-registration via the
44
+ // admin endpoints if they intend to grant real scopes.
45
+ const DEFAULT_PUBLIC_DCR_PROFILE = "client-generic";
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+ // Helpers
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+ function randomOpaqueToken() {
50
+ // 2× UUID → ~256 bits of entropy. Strip dashes for compactness.
51
+ return `${crypto.randomUUID()}${crypto.randomUUID()}`.replace(/-/g, "");
52
+ }
53
+ async function loadScopeProfile(profileId) {
54
+ return (await internalClient().query(
55
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
56
+ "oauth:getScopeProfile", { profileId }));
57
+ }
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+ // App
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ const app = new Hono();
62
+ // CORS — Claude web sends requests from claude.ai origin
63
+ app.use("*", cors({
64
+ origin: "*",
65
+ allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
66
+ allowHeaders: [
67
+ "Content-Type",
68
+ "Authorization",
69
+ "mcp-session-id",
70
+ "Last-Event-ID",
71
+ "mcp-protocol-version",
72
+ ],
73
+ exposeHeaders: ["mcp-session-id", "mcp-protocol-version"],
74
+ }));
75
+ // ─────────────────────────────────────────────────────────────────────────────
76
+ // OAuth 2.0 discovery (unauthenticated)
77
+ // ─────────────────────────────────────────────────────────────────────────────
78
+ // RFC 9728 — OAuth 2.0 Protected Resource Metadata
79
+ app.get("/.well-known/oauth-protected-resource", (c) => c.json({
80
+ resource: `${PUBLIC_BASE_URL}/mcp`,
81
+ authorization_servers: [PUBLIC_BASE_URL],
82
+ bearer_methods_supported: ["header"],
83
+ scopes_supported: ["vantage:read", "vantage:write"],
84
+ }));
85
+ // RFC 8414 — OAuth 2.0 Authorization Server Metadata
86
+ app.get("/.well-known/oauth-authorization-server", (c) => c.json({
87
+ issuer: PUBLIC_BASE_URL,
88
+ authorization_endpoint: `${PUBLIC_BASE_URL}/authorize`,
89
+ token_endpoint: `${PUBLIC_BASE_URL}/token`,
90
+ registration_endpoint: `${PUBLIC_BASE_URL}/register`,
91
+ response_types_supported: ["code"],
92
+ grant_types_supported: ["authorization_code", "refresh_token"],
93
+ code_challenge_methods_supported: ["S256"],
94
+ token_endpoint_auth_methods_supported: [
95
+ "client_secret_post",
96
+ "client_secret_basic",
97
+ "none",
98
+ ],
99
+ scopes_supported: ["vantage:read", "vantage:write"],
100
+ }));
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+ // RFC 7591 — Dynamic Client Registration
103
+ // Anonymous registrations get DEFAULT_PUBLIC_DCR_PROFILE ("client-generic").
104
+ // Pi must elevate the client via admin endpoint before real scopes are granted.
105
+ // ─────────────────────────────────────────────────────────────────────────────
106
+ app.post("/register", async (c) => {
107
+ let body = {};
108
+ try {
109
+ body = await c.req.json();
110
+ }
111
+ catch {
112
+ // allow empty body — Claude sometimes posts nothing
113
+ }
114
+ const redirectUris = Array.isArray(body.redirect_uris)
115
+ ? body.redirect_uris
116
+ : [];
117
+ const clientId = crypto.randomUUID();
118
+ const clientSecret = randomOpaqueToken();
119
+ const clientSecretHash = await sha256Hex(clientSecret);
120
+ const clientName = typeof body.client_name === "string" ? body.client_name : "anonymous-dcr";
121
+ // SECURITY: public DCR is ALWAYS bound to the deny-by-default profile. Do
122
+ // NOT read body.scope_profile here — an attacker could register with
123
+ // {"scope_profile": "master"} and chain through /authorize + /token to
124
+ // obtain master-level access. Non-default profiles are provisioned only
125
+ // via POST /admin/oauth/clients (master-token gated).
126
+ const scopeProfile = DEFAULT_PUBLIC_DCR_PROFILE;
127
+ try {
128
+ await internalClient().mutation(
129
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
130
+ "oauth:registerPublicClient", {
131
+ clientId,
132
+ clientSecretHash,
133
+ name: clientName,
134
+ redirectUris,
135
+ scopeProfile,
136
+ });
137
+ }
138
+ catch (err) {
139
+ const message = err instanceof Error ? err.message : String(err);
140
+ console.error("[oauth] /register failed:", message);
141
+ return c.json({ error: "server_error", error_description: "failed to persist client" }, 500);
142
+ }
143
+ return c.json({
144
+ client_id: clientId,
145
+ client_secret: clientSecret,
146
+ client_id_issued_at: Math.floor(Date.now() / 1000),
147
+ client_secret_expires_at: 0, // never expires
148
+ redirect_uris: redirectUris,
149
+ client_name: clientName,
150
+ token_endpoint_auth_method: "client_secret_post",
151
+ grant_types: ["authorization_code", "refresh_token"],
152
+ response_types: ["code"],
153
+ scope: "vantage:read vantage:write",
154
+ scope_profile: scopeProfile,
155
+ }, 201);
156
+ });
157
+ // ─────────────────────────────────────────────────────────────────────────────
158
+ // GET /authorize — auto-approve, no user consent UI (MVP, scoped)
159
+ // ─────────────────────────────────────────────────────────────────────────────
160
+ app.get("/authorize", async (c) => {
161
+ const q = c.req.query();
162
+ const clientId = q.client_id;
163
+ const redirectUri = q.redirect_uri;
164
+ const codeChallenge = q.code_challenge;
165
+ const codeChallengeMethod = q.code_challenge_method ?? "S256";
166
+ const state = q.state;
167
+ const scope = q.scope ?? "vantage:read vantage:write";
168
+ const responseType = q.response_type;
169
+ if (!clientId || !redirectUri || !codeChallenge) {
170
+ return c.json({
171
+ error: "invalid_request",
172
+ error_description: "missing client_id, redirect_uri, or code_challenge",
173
+ }, 400);
174
+ }
175
+ if (responseType && responseType !== "code") {
176
+ return c.json({ error: "unsupported_response_type" }, 400);
177
+ }
178
+ if (codeChallengeMethod !== "S256") {
179
+ return c.json({ error: "invalid_request", error_description: "only S256 supported" }, 400);
180
+ }
181
+ // Verify the client exists and is not revoked
182
+ const client = (await internalClient().query(
183
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
184
+ "oauth:getClientByClientId", { clientId }));
185
+ if (!client) {
186
+ return c.json({ error: "invalid_client", error_description: "unknown client_id" }, 400);
187
+ }
188
+ if (client.revokedAt !== undefined) {
189
+ return c.json({ error: "invalid_client", error_description: "client revoked" }, 400);
190
+ }
191
+ const masterTokenForAuthCode = process.env.BEARER_SECRET_MASTER;
192
+ if (!masterTokenForAuthCode) {
193
+ console.error("[oauth] BEARER_SECRET_MASTER not set — cannot mint authorization code");
194
+ return c.json({ error: "server_misconfigured" }, 500);
195
+ }
196
+ const code = randomOpaqueToken();
197
+ await internalClient().mutation(
198
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
199
+ "oauth:createAuthorizationCode", {
200
+ callerToken: masterTokenForAuthCode,
201
+ code,
202
+ clientId,
203
+ redirectUri,
204
+ codeChallenge,
205
+ scope,
206
+ // userId defaults to the scope profile (1:1 with the client by default).
207
+ // When future multi-user consent UI ships, this resolves to the Clerk user.
208
+ userId: client.scopeProfile,
209
+ expiresAt: Date.now() + AUTH_CODE_TTL_SECONDS * 1000,
210
+ });
211
+ const redirect = new URL(redirectUri);
212
+ redirect.searchParams.set("code", code);
213
+ if (state)
214
+ redirect.searchParams.set("state", state);
215
+ return c.redirect(redirect.toString(), 302);
216
+ });
217
+ // ─────────────────────────────────────────────────────────────────────────────
218
+ // POST /token — authorization_code + refresh_token grants
219
+ // ─────────────────────────────────────────────────────────────────────────────
220
+ app.post("/token", async (c) => {
221
+ const contentType = c.req.header("Content-Type") ?? "";
222
+ let body = {};
223
+ if (contentType.includes("application/x-www-form-urlencoded")) {
224
+ const text = await c.req.text();
225
+ body = Object.fromEntries(new URLSearchParams(text));
226
+ }
227
+ else {
228
+ try {
229
+ body = (await c.req.json());
230
+ }
231
+ catch {
232
+ return c.json({ error: "invalid_request", error_description: "unreadable body" }, 400);
233
+ }
234
+ }
235
+ const grantType = body.grant_type;
236
+ // ── authorization_code grant ────────────────────────────────────────────
237
+ if (grantType === "authorization_code") {
238
+ const { code, code_verifier: codeVerifier, redirect_uri: redirectUri, client_id: clientId, } = body;
239
+ if (!code || !codeVerifier) {
240
+ return c.json({
241
+ error: "invalid_request",
242
+ error_description: "missing code or code_verifier",
243
+ }, 400);
244
+ }
245
+ // Consume code (atomic: delete + return)
246
+ const record = (await internalClient().mutation(
247
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
248
+ "oauth:consumeAuthorizationCode", { code }));
249
+ if (!record) {
250
+ return c.json({ error: "invalid_grant", error_description: "unknown code" }, 400);
251
+ }
252
+ if (Date.now() > record.expiresAt) {
253
+ return c.json({ error: "invalid_grant", error_description: "code expired" }, 400);
254
+ }
255
+ if (redirectUri && redirectUri !== record.redirectUri) {
256
+ return c.json({
257
+ error: "invalid_grant",
258
+ error_description: "redirect_uri mismatch",
259
+ }, 400);
260
+ }
261
+ if (clientId && clientId !== record.clientId) {
262
+ return c.json({ error: "invalid_grant", error_description: "client_id mismatch" }, 400);
263
+ }
264
+ // PKCE: base64url(SHA256(code_verifier)) === code_challenge
265
+ const challengeCheck = await sha256Base64Url(codeVerifier);
266
+ if (challengeCheck !== record.codeChallenge) {
267
+ return c.json({
268
+ error: "invalid_grant",
269
+ error_description: "PKCE verification failed",
270
+ }, 400);
271
+ }
272
+ // Resolve the client's scope profile (materialised into the token row)
273
+ const client = (await internalClient().query(
274
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
275
+ "oauth:getClientByClientId", { clientId: record.clientId }));
276
+ if (!client || client.revokedAt !== undefined) {
277
+ return c.json({ error: "invalid_client" }, 400);
278
+ }
279
+ const profile = await loadScopeProfile(client.scopeProfile);
280
+ if (!profile) {
281
+ console.error("[oauth] scope_profile not found during token issue:", client.scopeProfile);
282
+ return c.json({ error: "server_error" }, 500);
283
+ }
284
+ // Issue access_token + refresh_token
285
+ const masterTokenForIssue = process.env.BEARER_SECRET_MASTER;
286
+ if (!masterTokenForIssue) {
287
+ console.error("[oauth] BEARER_SECRET_MASTER not set — cannot mint tokens");
288
+ return c.json({ error: "server_misconfigured" }, 500);
289
+ }
290
+ const accessToken = randomOpaqueToken();
291
+ const refreshToken = randomOpaqueToken();
292
+ const accessTokenHash = await sha256Hex(accessToken);
293
+ const refreshTokenHash = await sha256Hex(refreshToken);
294
+ const now = Date.now();
295
+ await internalClient().mutation(
296
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
297
+ "oauth:createAccessToken", {
298
+ callerToken: masterTokenForIssue,
299
+ tokenHash: accessTokenHash,
300
+ clientId: record.clientId,
301
+ userId: record.userId,
302
+ scopes: record.scope.split(/\s+/).filter(Boolean),
303
+ scopeProfile: profile.profileId,
304
+ fromAllowList: profile.fromAllowList,
305
+ namespaceReadPrefixes: profile.namespaceReadPrefixes,
306
+ namespaceWritePrefixes: profile.namespaceWritePrefixes,
307
+ expiresAt: now + ACCESS_TOKEN_TTL_SECONDS * 1000,
308
+ refreshTokenHash,
309
+ });
310
+ await internalClient().mutation(
311
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
312
+ "oauth:createRefreshToken", {
313
+ callerToken: masterTokenForIssue,
314
+ tokenHash: refreshTokenHash,
315
+ clientId: record.clientId,
316
+ userId: record.userId,
317
+ scopeProfile: profile.profileId,
318
+ expiresAt: now + REFRESH_TOKEN_TTL_SECONDS * 1000,
319
+ });
320
+ return c.json({
321
+ access_token: accessToken,
322
+ token_type: "Bearer",
323
+ expires_in: ACCESS_TOKEN_TTL_SECONDS,
324
+ refresh_token: refreshToken,
325
+ scope: record.scope,
326
+ });
327
+ }
328
+ // ── refresh_token grant ─────────────────────────────────────────────────
329
+ if (grantType === "refresh_token") {
330
+ const refreshTokenRaw = body.refresh_token;
331
+ if (!refreshTokenRaw) {
332
+ return c.json({ error: "invalid_request" }, 400);
333
+ }
334
+ const refreshTokenHash = await sha256Hex(refreshTokenRaw);
335
+ const record = (await internalClient().query(
336
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
337
+ "oauth:getRefreshTokenByHash", { tokenHash: refreshTokenHash }));
338
+ if (!record) {
339
+ return c.json({ error: "invalid_grant" }, 400);
340
+ }
341
+ const profile = await loadScopeProfile(record.scopeProfile);
342
+ if (!profile) {
343
+ return c.json({ error: "server_error" }, 500);
344
+ }
345
+ const masterTokenForRefresh = process.env.BEARER_SECRET_MASTER;
346
+ if (!masterTokenForRefresh) {
347
+ console.error("[oauth] BEARER_SECRET_MASTER not set — cannot refresh token");
348
+ return c.json({ error: "server_misconfigured" }, 500);
349
+ }
350
+ const accessToken = randomOpaqueToken();
351
+ const accessTokenHash = await sha256Hex(accessToken);
352
+ const now = Date.now();
353
+ await internalClient().mutation(
354
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
355
+ "oauth:createAccessToken", {
356
+ callerToken: masterTokenForRefresh,
357
+ tokenHash: accessTokenHash,
358
+ clientId: record.clientId,
359
+ userId: record.userId,
360
+ scopes: ["vantage:read", "vantage:write"],
361
+ scopeProfile: profile.profileId,
362
+ fromAllowList: profile.fromAllowList,
363
+ namespaceReadPrefixes: profile.namespaceReadPrefixes,
364
+ namespaceWritePrefixes: profile.namespaceWritePrefixes,
365
+ expiresAt: now + ACCESS_TOKEN_TTL_SECONDS * 1000,
366
+ refreshTokenHash,
367
+ });
368
+ return c.json({
369
+ access_token: accessToken,
370
+ token_type: "Bearer",
371
+ expires_in: ACCESS_TOKEN_TTL_SECONDS,
372
+ refresh_token: refreshTokenRaw, // reused
373
+ scope: "vantage:read vantage:write",
374
+ });
375
+ }
376
+ return c.json({ error: "unsupported_grant_type" }, 400);
377
+ });
378
+ // ─────────────────────────────────────────────────────────────────────────────
379
+ // Health check — unauthenticated, used by Railway health probes
380
+ // ─────────────────────────────────────────────────────────────────────────────
381
+ app.get("/health", (c) => c.json({
382
+ status: "ok",
383
+ service: "vantage-peers-mcp-http",
384
+ version: "2.1.0",
385
+ transport: "streamable-http",
386
+ oauth: "scoped-tokens",
387
+ }));
388
+ // ─────────────────────────────────────────────────────────────────────────────
389
+ // Admin endpoints — master token only
390
+ // Used by Pi to provision OAuth clients for external users (Marie, VIP).
391
+ // ─────────────────────────────────────────────────────────────────────────────
392
+ const admin = new Hono();
393
+ admin.use("*", masterOnlyMiddleware());
394
+ // POST /admin/oauth/clients — create client, returns raw secret ONCE
395
+ admin.post("/oauth/clients", async (c) => {
396
+ const masterToken = process.env.BEARER_SECRET_MASTER;
397
+ if (!masterToken) {
398
+ return c.json({ error: "server_misconfigured" }, 500);
399
+ }
400
+ let body = {};
401
+ try {
402
+ body = await c.req.json();
403
+ }
404
+ catch {
405
+ return c.json({ error: "invalid_request" }, 400);
406
+ }
407
+ const name = typeof body.name === "string" ? body.name : null;
408
+ const scopeProfile = typeof body.scope_profile === "string" ? body.scope_profile : null;
409
+ const redirectUris = Array.isArray(body.redirect_uris)
410
+ ? body.redirect_uris
411
+ : [];
412
+ if (!name || !scopeProfile) {
413
+ return c.json({
414
+ error: "invalid_request",
415
+ error_description: "name and scope_profile are required",
416
+ }, 400);
417
+ }
418
+ const profile = await loadScopeProfile(scopeProfile);
419
+ if (!profile) {
420
+ return c.json({ error: "invalid_scope_profile", scopeProfile }, 400);
421
+ }
422
+ const clientId = crypto.randomUUID();
423
+ const clientSecret = randomOpaqueToken();
424
+ const clientSecretHash = await sha256Hex(clientSecret);
425
+ try {
426
+ await internalClient().mutation(
427
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
428
+ "oauth:createClient", {
429
+ callerToken: masterToken,
430
+ clientId,
431
+ clientSecretHash,
432
+ name,
433
+ redirectUris,
434
+ scopeProfile,
435
+ });
436
+ }
437
+ catch (err) {
438
+ const message = err instanceof Error ? err.message : String(err);
439
+ console.error("[admin] createClient failed:", message);
440
+ return c.json({ error: "server_error", detail: message }, 500);
441
+ }
442
+ return c.json({
443
+ client_id: clientId,
444
+ client_secret: clientSecret, // RAW — returned once, never again
445
+ name,
446
+ scope_profile: scopeProfile,
447
+ redirect_uris: redirectUris,
448
+ }, 201);
449
+ });
450
+ // GET /admin/oauth/clients — list (no secrets)
451
+ admin.get("/oauth/clients", async (c) => {
452
+ const masterToken = process.env.BEARER_SECRET_MASTER;
453
+ if (!masterToken)
454
+ return c.json({ error: "server_misconfigured" }, 500);
455
+ const rows = await internalClient().query(
456
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
457
+ "oauth:listClients", { callerToken: masterToken });
458
+ return c.json({ clients: rows });
459
+ });
460
+ // DELETE /admin/oauth/clients/:clientId — revoke client + all its tokens
461
+ admin.delete("/oauth/clients/:clientId", async (c) => {
462
+ const masterToken = process.env.BEARER_SECRET_MASTER;
463
+ if (!masterToken)
464
+ return c.json({ error: "server_misconfigured" }, 500);
465
+ const clientId = c.req.param("clientId");
466
+ const result = await internalClient().mutation(
467
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
468
+ "oauth:deleteClient", { callerToken: masterToken, clientId });
469
+ return c.json(result);
470
+ });
471
+ // POST /admin/oauth/seed-profiles — idempotent; safe to re-run after deploy
472
+ admin.post("/oauth/seed-profiles", async (c) => {
473
+ const masterToken = process.env.BEARER_SECRET_MASTER;
474
+ if (!masterToken)
475
+ return c.json({ error: "server_misconfigured" }, 500);
476
+ const created = await internalClient().mutation(
477
+ // biome-ignore lint/suspicious/noExplicitAny: Convex string API
478
+ "oauth:seedDefaultProfiles", { callerToken: masterToken });
479
+ return c.json({ created });
480
+ });
481
+ app.route("/admin", admin);
482
+ // ─────────────────────────────────────────────────────────────────────────────
483
+ // MCP endpoint — authenticated, stateless per-request server
484
+ // ─────────────────────────────────────────────────────────────────────────────
485
+ app.all("/mcp", bearerAuthMiddleware(), async (c) => {
486
+ const tenant = c.get("tenant");
487
+ const oauthCtx = c.get("oauthContext");
488
+ // Per-request Convex client bound to the resolved deployment
489
+ const convex = new ConvexHttpClient(tenant.convexUrl);
490
+ // Fresh McpServer per request — stateless mode, no session leakage
491
+ const server = new McpServer({
492
+ name: "vantage-peers",
493
+ version: "2.1.0",
494
+ });
495
+ registerTools(server, convex, oauthCtx);
496
+ const transport = new WebStandardStreamableHTTPServerTransport();
497
+ await server.connect(transport);
498
+ return transport.handleRequest(c.req.raw);
499
+ });
500
+ // ─────────────────────────────────────────────────────────────────────────────
501
+ // Start
502
+ // ─────────────────────────────────────────────────────────────────────────────
503
+ const PORT = Number(process.env.PORT ?? 3000);
504
+ const HOSTNAME = "0.0.0.0";
505
+ // Explicit Bun.serve() — does not rely on default-export auto-detection,
506
+ // which can fail when started via `bun run <file>` (vs `bun <file>`).
507
+ // @ts-expect-error — Bun global available at runtime on Railway
508
+ const server = Bun.serve({
509
+ port: PORT,
510
+ hostname: HOSTNAME,
511
+ fetch: app.fetch,
512
+ });
513
+ console.log(`[vantage-peers-mcp] HTTP transport listening on ${server.hostname}:${server.port}`);
514
+ console.log(`[vantage-peers-mcp] Health: http://${server.hostname}:${server.port}/health`);
515
+ console.log(`[vantage-peers-mcp] MCP: http://${server.hostname}:${server.port}/mcp`);
package/dist/server.js CHANGED
@@ -50,11 +50,11 @@ const memoryTypeSchema = z
50
50
  .enum(["user", "feedback", "project", "reference", "episode"])
51
51
  .describe("Memory classification type");
52
52
  // Open string — validated at runtime by the backend (issue #132).
53
- // Known defaults: pi, tau, phi, sigma, omega, zeta, eta, alpha, lambda, victor, system.
53
+ // Known defaults: pi, tau, phi, sigma, omega, zeta, eta, kappa, alpha, lambda, victor, system.
54
54
  // New internal orchestrators use Greek letters (lowercase); external client orchestrators use free lowercase strings.
55
55
  const creatorSchema = z
56
56
  .string()
57
- .describe("Orchestrator role name (e.g. pi, tau, phi, sigma, omega, zeta, eta, alpha, lambda, victor, laurent, or any custom client role (lowercase string)). " +
57
+ .describe("Orchestrator role name (e.g. pi, tau, phi, sigma, omega, zeta, eta, kappa, alpha, lambda, victor, laurent, or any custom client role (lowercase string)). " +
58
58
  "New internal orchestrators use Greek letters (lowercase); external client orchestrators use free lowercase strings.");
59
59
  const severitySchema = z
60
60
  .enum(["critical", "major", "minor"])
@@ -474,7 +474,7 @@ server.tool("list_memories", "List active memories for a namespace, ordered newe
474
474
  server.tool("send_message", "Send a message to one, many, or all orchestrators. " +
475
475
  "channel: 'broadcast' = all, 'tau' = role DM, 'pi-vps' = instance DM, 'tau,phi' = multi. " +
476
476
  "Creates message + one receipt per recipient. Replaces claude-peers send_message.", {
477
- from: creatorSchema.describe("Sender role (e.g. pi, tau, phi, sigma, alpha, lambda, victor, or any custom role)"),
477
+ from: creatorSchema.describe("Sender role (e.g. pi, tau, phi, sigma, omega, zeta, eta, kappa, alpha, lambda, victor, or any custom role)"),
478
478
  fromInstanceId: z
479
479
  .string()
480
480
  .optional()
@@ -521,7 +521,7 @@ server.tool("send_message", "Send a message to one, many, or all orchestrators.
521
521
  server.tool("check_messages", "Check for unread messages. Returns messages with receiptIds for marking as read. " +
522
522
  "If recipientInstanceId is provided, returns instance-targeted + role-level messages. " +
523
523
  "Replaces claude-peers check_messages.", {
524
- recipient: creatorSchema.describe("Orchestrator role (e.g. pi, tau, phi, sigma, alpha, lambda, victor, or any custom role)"),
524
+ recipient: creatorSchema.describe("Orchestrator role (e.g. pi, tau, phi, sigma, omega, zeta, eta, kappa, alpha, lambda, victor, or any custom role)"),
525
525
  recipientInstanceId: z
526
526
  .string()
527
527
  .optional()
@@ -752,7 +752,7 @@ server.tool("list_broadcast_status", "Show who read a broadcast message and who
752
752
  // Open string — validated at runtime by the backend (issue #132).
753
753
  const assigneeSchema = z
754
754
  .string()
755
- .describe("Orchestrator to assign to (e.g. pi, tau, phi, sigma, omega, zeta, eta, alpha, lambda, victor, laurent, or any custom client role (lowercase string)). " +
755
+ .describe("Orchestrator to assign to (e.g. pi, tau, phi, sigma, omega, zeta, eta, kappa, alpha, lambda, victor, laurent, or any custom client role (lowercase string)). " +
756
756
  "New internal orchestrators use Greek letters (lowercase); external client orchestrators use free lowercase strings.");
757
757
  const prioritySchema = z
758
758
  .enum(["urgent", "high", "medium", "low"])
@@ -1455,6 +1455,60 @@ server.tool("create_briefing_note", "Create a briefing note — a structured rec
1455
1455
  }
1456
1456
  });
1457
1457
  // ─────────────────────────────────────────────────────────────────────────────
1458
+ // Tool: update_briefing_note
1459
+ // ─────────────────────────────────────────────────────────────────────────────
1460
+ server.tool("update_briefing_note", "Update an existing briefing note. Partial-update — only provided fields are patched. " +
1461
+ "Arrays (decisions, linkedMemoryIds, participants) are FULL REPLACE, not append. " +
1462
+ "RBAC : caller must be createdBy or 'system'. " +
1463
+ "Sets updatedAt + updatedBy automatically.", {
1464
+ noteId: z
1465
+ .string()
1466
+ .describe("Convex document ID of the briefing note to update"),
1467
+ callerOrchestrator: creatorSchema.describe("Orchestrator role making the update — must match createdBy or be 'system' (RBAC deny-by-default)"),
1468
+ title: z.string().optional().describe("Optional new title — full replace"),
1469
+ topic: z.string().optional().describe("Optional new topic — full replace"),
1470
+ participants: z
1471
+ .array(z.string())
1472
+ .optional()
1473
+ .describe("Optional new participants array — full replace, not append"),
1474
+ content: z
1475
+ .string()
1476
+ .optional()
1477
+ .describe("Optional new content — full replace"),
1478
+ decisions: z
1479
+ .array(z.string())
1480
+ .optional()
1481
+ .describe("Optional new decisions array — full replace, not append"),
1482
+ linkedMemoryIds: z
1483
+ .array(z.string())
1484
+ .optional()
1485
+ .describe("Optional new linkedMemoryIds array — full replace, not append. Each ID must point to memories table."),
1486
+ }, async ({ noteId, callerOrchestrator, title, topic, participants, content, decisions, linkedMemoryIds, }) => {
1487
+ try {
1488
+ await convex.mutation("briefingNotes:update", {
1489
+ noteId: noteId,
1490
+ callerOrchestrator,
1491
+ title,
1492
+ topic,
1493
+ participants,
1494
+ content,
1495
+ decisions,
1496
+ linkedMemoryIds: linkedMemoryIds,
1497
+ });
1498
+ return {
1499
+ content: [
1500
+ {
1501
+ type: "text",
1502
+ text: JSON.stringify({ noteId, updated: true }, null, 2),
1503
+ },
1504
+ ],
1505
+ };
1506
+ }
1507
+ catch (error) {
1508
+ return mcpError(error.message ?? String(error));
1509
+ }
1510
+ });
1511
+ // ─────────────────────────────────────────────────────────────────────────────
1458
1512
  // Tool: list_briefing_notes
1459
1513
  // ─────────────────────────────────────────────────────────────────────────────
1460
1514
  server.tool("list_briefing_notes", "List briefing notes, optionally filtered by topic. Returns newest first.", {