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.
- package/dist/server-http.d.ts +27 -0
- package/dist/server-http.js +515 -0
- package/dist/server.js +59 -5
- package/dist/src/auth.d.ts +73 -0
- package/dist/src/auth.js +271 -0
- package/dist/src/tools.d.ts +41 -0
- package/dist/src/tools.js +3243 -0
- package/package.json +7 -5
|
@@ -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.", {
|