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 +45 -0
- 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 +17 -6
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# vantage-peers-mcp
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/vantage-peers-mcp)
|
|
4
|
+
[](https://www.npmjs.com/package/vantage-peers-mcp)
|
|
5
|
+
[](https://github.com/vantageos-agency/vantage-peers/blob/main/LICENSE)
|
|
6
|
+
[]()
|
|
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`);
|