vantage-peers-mcp 2.0.1 → 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.
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # vantage-peers-mcp
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/vantage-peers-mcp)](https://www.npmjs.com/package/vantage-peers-mcp)
4
+ [![npm downloads](https://img.shields.io/npm/dm/vantage-peers-mcp)](https://www.npmjs.com/package/vantage-peers-mcp)
5
+ [![License: FSL-1.1-Apache-2.0](https://img.shields.io/badge/license-FSL--1.1--Apache--2.0-blue)](https://github.com/vantageos-agency/vantage-peers/blob/main/LICENSE)
6
+ [![Tests: 82/82](https://img.shields.io/badge/MCP_tools-82_registered-green)]()
7
+
3
8
  MCP server for [VantagePeers](https://vantagepeers.com) — shared memory, messaging, and task coordination for AI agent teams.
4
9
 
5
10
  82 tools across 18 categories: memory, profiles, tasks, missions, mission templates, messages, diary, briefing notes, search (RAG), issues, fix patterns, error monitoring, deployments, business units, components, mandates, recurring tasks, and session.
@@ -174,11 +179,51 @@ await client.mutation(api.messages.sendMessage, {
174
179
 
175
180
  **Security:** Never commit deploy keys to git. Use environment variables or a secrets manager.
176
181
 
182
+ ## Orchestrator Roles
183
+
184
+ All orchestrator names are open strings — any lowercase name is accepted. The following are conventions used by the VantageOS team:
185
+
186
+ | Role | Purpose |
187
+ |------|---------|
188
+ | `pi` | Lead orchestrator — planning, delegation, strategy |
189
+ | `tau` | Frontend specialist — UI, design systems, components |
190
+ | `phi` | Backend specialist — APIs, database, infrastructure |
191
+ | `sigma` | Infrastructure — deployments, CI/CD, monitoring |
192
+ | `omega` | VantageRegistry — agent and skill catalog |
193
+ | `zeta` | Project-specific specialist |
194
+ | `eta` | Code reviewer — GitHub PR reviews |
195
+ | `alpha` | Perello Consulting — client delivery |
196
+ | `lambda` | Tech intelligence — research and monitoring |
197
+ | `victor` | HR / people operations |
198
+ | `system` | Reserved for automated/webhook operations (bypasses RBAC). Not a real agent. |
199
+
200
+ > **Custom roles:** any lowercase string is a valid orchestrator name. Enterprise clients can use arbitrary role names for their own agent teams.
201
+
177
202
  ## Requirements
178
203
 
179
204
  - Node.js >= 18
180
205
  - A VantagePeers Convex deployment ([get started](https://vantagepeers.com/docs))
181
206
 
207
+ ## Changelog
208
+
209
+ ### 2.0.2 — 2026-04-14
210
+ - Added badges (npm version, downloads, license, tool count) to the published README
211
+ - Added Orchestrator Roles reference table including alpha, lambda, victor (Day 39 additions)
212
+ - Added note that any custom lowercase role name is accepted
213
+ - Added `bugs` URL and additional keywords to `package.json`
214
+
215
+ ### 2.0.1 — 2026-04-14
216
+ - Docstring fix in server.ts (minor)
217
+
218
+ ### 2.0.0
219
+ - Type-safe `api.ts` export for cross-deployment calls (`vantage-peers-mcp/api`)
220
+ - Deploy key authentication guide
221
+ - Mission Templates category (1 tool: `update_mission_template`)
222
+ - Programmatic API section in README
223
+
224
+ ### 1.x
225
+ - Initial public release with 82 MCP tools
226
+
182
227
  ## License
183
228
 
184
229
  FSL-1.1-Apache-2.0
@@ -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`);