vantage-peers-mcp 2.17.0 → 2.19.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/CHANGELOG.md +28 -0
- package/README.md +45 -4
- package/dist/server-http.js +72 -4
- package/dist/server.d.ts +3 -2
- package/dist/server.js +30 -11
- package/dist/src/auth.d.ts +82 -12
- package/dist/src/auth.js +237 -23
- package/dist/src/authenticatedConvexClient.d.ts +15 -0
- package/dist/src/authenticatedConvexClient.js +34 -2
- package/dist/src/registerTool.d.ts +41 -1
- package/dist/src/registerTool.js +58 -9
- package/dist/src/serviceAccountAuth.d.ts +20 -0
- package/dist/src/serviceAccountAuth.js +113 -19
- package/dist/src/socketRetry.d.ts +64 -0
- package/dist/src/socketRetry.js +88 -0
- package/dist/src/tools/importOkfBundle.d.ts +14 -0
- package/dist/src/tools/importOkfBundle.js +14 -0
- package/dist/src/tools.d.ts +39 -0
- package/dist/src/tools.js +814 -145
- package/dist/src/ui-resources/index.d.ts +5 -1
- package/dist/src/ui-resources/index.js +2 -2
- package/dist/src/ui-resources/primitives/briefing-note.d.ts +4 -1
- package/dist/src/ui-resources/primitives/briefing-note.js +16 -4
- package/package.json +4 -2
- package/tool-exposure.json +70 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased] — grant-aware mission/mandate visibility + bind JWT audience
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`check_messages` now renders `stuckInProgress` / `peersStuckOnYou` from the envelope (Day-156 reader-first; Eta REVISE PR #1218 dual-shape).** Missing / null / undefined keys default via `asCappedStuckList` so old Convex prod cannot throw `.length`. Accepts a raw `{taskId,title,age}[]` (already-deployed MCP) **or** `{entries,total,truncated}`. Empty unread + a stuck SIGNAL (`entries.length > 0` OR `truncated === true`) surfaces the block, never `"No new messages."` / Vide — a truncated empty list is a signal, not rest. `messages` / `staleInProgress` are unchanged; `pendingOnYou` is not revived.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`fail_task` tool — the third task terminal state (`failed`), distinct from `done`/`cancelled` (mission `k576mw0smxeqsg9wp7957njfsn8crey4`, commit `8c70e18`).** New tool, mandatory `failureNote`, output schema `{taskId, status: "failed"}`. `update_task`'s `updateTaskStatusSchema` now excludes both `"blocked"` and `"failed"` (client refused at the tool-input layer, mirroring the server-side `FAILED_VIA_UPDATE_REFUSED` gate). `taskStatusValues`/`taskStatusFilterSchema` gained `"failed"`. `createTaskOutputSchema.status` now reuses the canonical `taskStatusValues` array (was hand-typed and missing `"cancelled"`/`"failed"`). A blocker task reaching `failed` does NOT auto-release its waiters — see `convex`-side CHANGELOG for the reciprocal-unblock fix.
|
|
12
|
+
- **`block_task` gained an optional `blockedCause` arg** (`"peer_task" | "human" | "authorisation" | "other"`) naming WHAT the task is waiting on. Backward-compatible: optional, defaults server-side to `"other"`. `block_task`'s output JSON now always carries `blockedCause`.
|
|
13
|
+
|
|
14
|
+
### Security
|
|
15
|
+
|
|
16
|
+
- **Consult per-row grants on missions, mandates, and tasks (task `k174y9ra7pp8zed3bcczk6xaed8cpynp`, `@vantageos/cloud-identity` 0.5.0).** The shared scope filter was structurally blind to every per-row grant: a scoped identity named on a mission (`pilot`/`agents`), a mandate (`requestedBy`/`fulfilledBy`), or a task (`assignedTo`) could not read the row it was named on — the multi-org separation failing at exactly what it is sold for. Upgraded the dependency `^0.3.0` → `^0.5.0` (the old caret stopped at the 0.3 minor and never admitted 0.4.0) and passed `grantFields` at the read sites: `get_mission` → `["pilot","agents"]`; `list_mandates`/`get_mandate` → `["requestedBy","fulfilledBy"]`; `get_task`/`list_tasks_by_mission`/`search_tasks_by_keyword` → `["assignedTo"]` (mirrors `convex/tasks.ts` L88-89's `createdBy===caller || assignedTo===caller` OR), replacing the two-sided `createdBy`-remap workaround (the local-variant pattern the shared filter now subsumes). Fail-closed unchanged: a table declaring no grant fields behaves byte-identically to before. Eta REVISE on the first pass of PR #1204 (the original test suite called the package predicate directly instead of the real handlers — litmus test false); rewritten to drive `registerTools()` end-to-end via the same duck-typed McpServer/Convex harness `test/scope-aware-filter-wave-c1.test.ts` uses, RED→GREEN reproduced against the real `get_task`/`search_tasks_by_keyword` call sites. `briefingNotes.participants` stays deferred (task `k175ga65p654z200ydj7s8qv5s8cnxfc`); `recurringTasks.assignedTo`, `missionTemplates.steps[].assignedTo`, `businessUnits.coreTeam.agents`, and `diaries.orchestrator` remain unwired (out of this PR's scope — see PR #1204 `CLASS:` block). TDD strict — GREEN 13/13 under scoped non-creator identity `alice`; full mcp-server suite 1087 passed / 12 skipped, build clean + boot-check 4/4.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
- **Bind the `audience` claim where the Clerk session JWT is verified (MCP server standard Critical Rule 14, element 5).** `tryVerifyClerkJwt` (`src/auth.ts`) verified the token with `issuer` bound but not `audience`, so a token minted for one audience/client was accepted on another — cross-tenant / cross-resource replay (the single exploitable finding of the mcp-doctor conformance audit, `projects/vantage-peers/audits/mcp-server-conformance-audit.md`). Fix binds `audience: CLERK_JWT_AUDIENCE` (env, default `"convex"`) at the single `jwtVerify` site — the value mirrors `convex/auth.config.ts` `applicationID: "convex"` and the `CLERK_JWT_TEMPLATE` default, and the verified token is forwarded verbatim to Convex which already requires `aud === "convex"`, so no correctly-minted production token is rejected. Minimal by design: only the one verification site; the auth-layer/DCR/PRM flow and the three other (non-exploitable) audit findings are untouched. TDD strict — RED both poles (wrong-audience refused / correct-audience accepted / no-`aud` refused) reproduced firsthand, GREEN 3/3; full suite 1074 passed / 0 failed, `tsc --noEmit` exit 0.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Regenerate `mcp-server/bun.lock` so the grant-aware read actually reaches the customer-facing server (task `k170n5az8xt95gg0b6thrzgej98crnw3`).** PR #1204 (`f51bd709`) bumped `mcp-server/package.json` `@vantageos/cloud-identity` `^0.3.0` → `^0.5.0` and the root `bun.lock`, but never regenerated `mcp-server/bun.lock` — which stayed pinned at `@vantageos/cloud-identity@0.3.0`. The Railway build runs `bun install --frozen-lockfile`, which refuses a lockfile that would have to change (`error: lockfile had changes, but lockfile is frozen`), so the deploy failed **silently**: the previous container kept serving and nothing disagreed. Result: the grant-aware filter was ACTIVE on Convex but INACTIVE on the MCP server — the path a customer actually uses. Repair regenerates the lockfile **in an isolated tree** (never in the workspace, which would resolve against the workspace and reproduce the locally-passing / Railway-failing file), touching `mcp-server/bun.lock` only; the manifest is untouched (dependency scope did not move). The drift named: `@vantageos/cloud-identity` `0.3.0` → `0.5.0`. Proven pair, both poles firsthand: RED = stale lockfile + `^0.5.0` manifest under `--frozen-lockfile` → exit 1 (the exact Railway message); GREEN = repaired lockfile under `--frozen-lockfile` → exit 0. Activation (separate from merge) is a successful Railway deploy read back from the platform, then a scoped-identity probe of the grant filter — never the master credential, which bypasses the very check.
|
|
25
|
+
|
|
26
|
+
## [2.18.0] — 2026-08-11
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **Expose only the CORE tool surface (mission `vp-mcp-alias-cleanup-v1`, S8).** A data-driven allowlist (`mcp-server/tool-exposure.json`, `{"core":[...]}`) masks every tool whose `T2_verdict` is not `CORE` in `analysis/vantagepeers/vp-restructuring/vp-by-tool-day158.csv`. Masking ≠ deletion: non-CORE tools stay registered + handler-wired and are `disable()`d so `tools/list` does not advertise them; reverting = removing a line from the data file. Exposed = CORE ∩ registered = **66** tools; masked = **43** (registered 109). The exposure list is derived from the CSV, never typed; a name in the file matching no registered tool makes the server refuse to start, naming it. Applied at the single shared `registerTools` surface, so both stdio and HTTP transports (online clients) inherit the reduced surface. 5 CORE names from the day158 CSV are #1169-removed aliases whose CORE survivors stay exposed (zero capability lost). Tests: `test/tool-exposure.test.ts` 2/2, full suite 1071 passed / 0 failed, tsc 0.
|
|
30
|
+
|
|
3
31
|
## [2.17.0] — 2026-08-11
|
|
4
32
|
|
|
5
33
|
### Removed
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[]()
|
|
7
7
|
|
|
8
8
|
> **Package:** `vantage-peers-mcp` (plain — NOT `@vantageos/vantage-peers-mcp`)
|
|
9
|
-
> **Current version:** `2.
|
|
9
|
+
> **Current version:** `2.18.0` (S8 CORE tool-exposure filter release — server now advertises only 66 CORE tools of 109 registered; masking is data-driven (`tool-exposure.json`) and reversible, non-CORE tools stay registered/handler-wired but are not listed)
|
|
10
10
|
> **License:** FSL-1.1-Apache-2.0
|
|
11
11
|
> **Repo:** https://github.com/vantageos-agency/vantage-peers (full monorepo README at `/README.md`)
|
|
12
12
|
> **Docs:** https://vantagepeers.com/docs
|
|
@@ -227,17 +227,20 @@ The full registered list ships in `mcp-server/src/tools.ts` and is enumerated be
|
|
|
227
227
|
- `update_profile` — mutate an orchestrator profile (master-gated)
|
|
228
228
|
- `list_peers` — page through registered peers
|
|
229
229
|
|
|
230
|
-
### Tasks (
|
|
230
|
+
### Tasks (17)
|
|
231
231
|
- `create_task` — create a new task with VERIFICATION + TESTS blocks
|
|
232
232
|
- `list_tasks` — page through tasks with filters + `excludeAutoGenerated`
|
|
233
233
|
- `list_tasks_by_mission` — page through tasks for a single mission
|
|
234
234
|
- `get_task` — fetch a single task by id
|
|
235
235
|
- `update_task` — patch task fields (incl. cancel: `status="cancelled"` + `cancelReason`, creator-only)
|
|
236
|
-
- `start_task` — transition to `in_progress
|
|
236
|
+
- `start_task` — transition to `in_progress`; resumes rather than restarts when the task already carries worked time, and refuses when a segment is already open, naming the verb to call instead
|
|
237
|
+
- `pause_task` — close the open work segment and stop the clock without ending the task; paused is not blocked
|
|
238
|
+
- `resume_task` — open a new work segment and put the task back in `in_progress`
|
|
237
239
|
- `complete_task` — close with evidence-bound `completionNote`
|
|
238
240
|
- `checkout_task` — claim a task without starting
|
|
239
241
|
- `delete_task` — destructive delete (master-gated, blocked in prod; to retire an erroneous task use `update_task status="cancelled"` + `cancelReason`, not `complete_task`)
|
|
240
|
-
- `block_task` — mark blocked with reason
|
|
242
|
+
- `block_task` — mark blocked with reason and optional `blockedCause` (`peer_task`|`human`|`authorisation`|`other`) naming WHAT is being waited on
|
|
243
|
+
- `fail_task` — close a task as **failed**, a terminal state distinct from `done`/`cancelled`, with a mandatory `failureNote`
|
|
241
244
|
- `add_task_dependency` — add a predecessor
|
|
242
245
|
- `bulk_complete_tasks` — dry-run-default bulk close (cron-spam cleanup)
|
|
243
246
|
- `validate_task_payload` — client-side payload validation
|
|
@@ -278,6 +281,44 @@ Example — Pi queue cleaned of cron-spam:
|
|
|
278
281
|
|
|
279
282
|
Returns `{ items: Task[], nextCursor: string | null }`. `nextCursor` is `null` on the last page.
|
|
280
283
|
|
|
284
|
+
#### `block_task` — `blockedCause` discriminator (T1, PR #1208)
|
|
285
|
+
|
|
286
|
+
```
|
|
287
|
+
block_task(taskId, reason?, blockedOnTaskId?, blockedCause?, callerOrchestrator?)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
`blockedCause` is optional (defaults server-side to `"other"`) and states WHAT the task is waiting on:
|
|
291
|
+
|
|
292
|
+
| Value | Meaning |
|
|
293
|
+
|-------|---------|
|
|
294
|
+
| `human` | Waiting on a human answer/decision — an operator has to reply before work resumes. |
|
|
295
|
+
| `authorisation` | Waiting on a merge/publish/approval gate (e.g. Eta review, Pi merge sign-off). |
|
|
296
|
+
| `peer_task` | A plain upstream dependency on another live task — requires a cited `blockedOnTaskId` (refused otherwise: `BLOCKED_CAUSE_PEER_TASK_REQUIRES_LINK`). |
|
|
297
|
+
| `other` | None of the above, or the default when omitted. |
|
|
298
|
+
|
|
299
|
+
`blockedCause` is orthogonal input data, not a caller-written presentation state — the reader-facing "waiting-on" label is always DERIVED from `{status, blockedCause}`, never written directly. Pre-existing blocked rows (created before this field existed) read back as `"other"`.
|
|
300
|
+
|
|
301
|
+
#### `fail_task` — the third terminal state (T1, PR #1208)
|
|
302
|
+
|
|
303
|
+
```
|
|
304
|
+
fail_task(taskId, failureNote, callerOrchestrator?)
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Marks a task **failed** — a terminal status distinct from `done` (succeeded) and `cancelled` (retired before/without attempting the work). Use it when the work was genuinely attempted and did not succeed.
|
|
308
|
+
|
|
309
|
+
- `failureNote` is **mandatory** and non-empty — describes how the work ended in failure.
|
|
310
|
+
- This is the **only** door to the failed state: `update_task status="failed"` is refused server-side (`FAILED_VIA_UPDATE_REFUSED`), the same way `update_task status="blocked"` is refused. There is no field for a closer to default past — only a distinct named tool to call.
|
|
311
|
+
- A task already `done`/`cancelled`/`failed` cannot be re-terminated as failed (`CANNOT_FAIL_CLOSED_TASK`).
|
|
312
|
+
- **Waiters are NOT auto-released when a blocker fails.** Any task blocked on the failed one stays `blocked`, with `blockedOnTaskId` intact — the fleet does not silently treat a failed prerequisite as though it held. The waiter's owner instead receives a `BLOCKER_FAILED:` notification naming the failure; the block must be re-routed by a human/orchestrator decision, never auto-cleared. (Contrast: when a blocker reaches `done`, its waiters ARE swept to `todo` automatically — only success auto-releases.)
|
|
313
|
+
|
|
314
|
+
Example:
|
|
315
|
+
```json
|
|
316
|
+
{
|
|
317
|
+
"tool": "fail_task",
|
|
318
|
+
"arguments": { "taskId": "k178d3ns...", "failureNote": "Migration errored on row 4102, rolled back cleanly", "callerOrchestrator": "beta" }
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
281
322
|
#### `bulk_complete_tasks` — args schema + dry-run-default safety (PR-F)
|
|
282
323
|
|
|
283
324
|
```
|
package/dist/server-http.js
CHANGED
|
@@ -35,7 +35,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
|
|
|
35
35
|
import { timingSafeEqual } from "@vantageos/cloud-identity";
|
|
36
36
|
import { Hono } from "hono";
|
|
37
37
|
import { cors } from "hono/cors";
|
|
38
|
-
import { bearerAuthMiddleware, internalClient, masterOnlyMiddleware, sha256Base64Url, sha256Hex, } from "./src/auth.js";
|
|
38
|
+
import { bearerAuthMiddleware, internalClient, isMasterScope, masterOnlyMiddleware, sha256Base64Url, sha256Hex, } from "./src/auth.js";
|
|
39
39
|
import { selectConvexClientForRequest } from "./src/authenticatedConvexClient.js";
|
|
40
40
|
import { registerTools } from "./src/tools.js";
|
|
41
41
|
import { listUiResources, readUiResource } from "./src/ui-resources/index.js";
|
|
@@ -228,8 +228,8 @@ app.get("/.well-known/oauth-authorization-server", (c) => {
|
|
|
228
228
|
});
|
|
229
229
|
});
|
|
230
230
|
const registerRateBuckets = new Map();
|
|
231
|
-
const REGISTER_RATE_LIMIT = 5;
|
|
232
|
-
const REGISTER_RATE_WINDOW_MS = 60_000;
|
|
231
|
+
const REGISTER_RATE_LIMIT = 5; // maxPerWindow: 5
|
|
232
|
+
const REGISTER_RATE_WINDOW_MS = 60_000; // windowMs: 60_000
|
|
233
233
|
function checkRegisterRateLimit(ip) {
|
|
234
234
|
const now = Date.now();
|
|
235
235
|
const bucket = registerRateBuckets.get(ip);
|
|
@@ -555,6 +555,7 @@ app.post("/token", async (c) => {
|
|
|
555
555
|
namespaceWritePrefixes: profile.namespaceWritePrefixes,
|
|
556
556
|
expiresAt: now + ACCESS_TOKEN_TTL_SECONDS * 1000,
|
|
557
557
|
refreshTokenHash,
|
|
558
|
+
clerkOrgSlug: profile.clerkOrgSlug,
|
|
558
559
|
});
|
|
559
560
|
await internalClient().mutation(
|
|
560
561
|
// biome-ignore lint/suspicious/noExplicitAny: Convex string API
|
|
@@ -641,6 +642,7 @@ app.post("/token", async (c) => {
|
|
|
641
642
|
namespaceWritePrefixes: profile.namespaceWritePrefixes,
|
|
642
643
|
expiresAt: now + ACCESS_TOKEN_TTL_SECONDS * 1000,
|
|
643
644
|
refreshTokenHash,
|
|
645
|
+
clerkOrgSlug: profile.clerkOrgSlug,
|
|
644
646
|
});
|
|
645
647
|
return c.json({
|
|
646
648
|
access_token: accessToken,
|
|
@@ -743,6 +745,61 @@ admin.post("/oauth/clients", async (c) => {
|
|
|
743
745
|
redirect_uris: redirectUris,
|
|
744
746
|
}, 201);
|
|
745
747
|
});
|
|
748
|
+
// POST /admin/organizations — one-shot org + orchestrator seats
|
|
749
|
+
admin.post("/organizations", async (c) => {
|
|
750
|
+
const masterToken = process.env.BEARER_SECRET_MASTER;
|
|
751
|
+
if (!masterToken) {
|
|
752
|
+
return c.json({ error: "server_misconfigured" }, 500);
|
|
753
|
+
}
|
|
754
|
+
let body = {};
|
|
755
|
+
try {
|
|
756
|
+
body = await c.req.json();
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
return c.json({ error: "invalid_request" }, 400);
|
|
760
|
+
}
|
|
761
|
+
const clerkOrgSlug = typeof body.clerkOrgSlug === "string" ? body.clerkOrgSlug : null;
|
|
762
|
+
const displayName = typeof body.displayName === "string" ? body.displayName : null;
|
|
763
|
+
const orchestratorsRaw = Array.isArray(body.orchestrators)
|
|
764
|
+
? body.orchestrators
|
|
765
|
+
: null;
|
|
766
|
+
if (!clerkOrgSlug || !displayName || !orchestratorsRaw) {
|
|
767
|
+
return c.json({
|
|
768
|
+
error: "invalid_request",
|
|
769
|
+
error_description: "clerkOrgSlug, displayName, and orchestrators are required",
|
|
770
|
+
}, 400);
|
|
771
|
+
}
|
|
772
|
+
const orchestrators = orchestratorsRaw
|
|
773
|
+
.map((row) => {
|
|
774
|
+
if (row && typeof row === "object" && "name" in row) {
|
|
775
|
+
const name = row.name;
|
|
776
|
+
return typeof name === "string" ? { name } : null;
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
})
|
|
780
|
+
.filter((row) => row !== null);
|
|
781
|
+
const scopes = Array.isArray(body.scopes)
|
|
782
|
+
? body.scopes.filter((s) => typeof s === "string")
|
|
783
|
+
: undefined;
|
|
784
|
+
try {
|
|
785
|
+
const result = await internalClient().mutation(
|
|
786
|
+
// biome-ignore lint/suspicious/noExplicitAny: Convex string API
|
|
787
|
+
"oauth:provisionOrganization", {
|
|
788
|
+
callerToken: masterToken,
|
|
789
|
+
clerkOrgSlug,
|
|
790
|
+
displayName,
|
|
791
|
+
orchestrators,
|
|
792
|
+
scopes,
|
|
793
|
+
});
|
|
794
|
+
return c.json(result, result.replay ? 200 : 201);
|
|
795
|
+
}
|
|
796
|
+
catch (err) {
|
|
797
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
798
|
+
console.error("[admin] provisionOrganization failed:", message);
|
|
799
|
+
const status = message.includes("Unauthorized") ? 401 : 400;
|
|
800
|
+
return c.json({ error: "provision_failed", detail: message }, status);
|
|
801
|
+
}
|
|
802
|
+
});
|
|
746
803
|
// GET /admin/oauth/clients — list (no secrets)
|
|
747
804
|
admin.get("/oauth/clients", async (c) => {
|
|
748
805
|
const masterToken = process.env.BEARER_SECRET_MASTER;
|
|
@@ -981,6 +1038,7 @@ admin.post("/oauth/access-tokens", async (c) => {
|
|
|
981
1038
|
namespaceReadPrefixes,
|
|
982
1039
|
namespaceWritePrefixes,
|
|
983
1040
|
expiresAt,
|
|
1041
|
+
clerkOrgSlug: profile.clerkOrgSlug,
|
|
984
1042
|
});
|
|
985
1043
|
}
|
|
986
1044
|
catch (err) {
|
|
@@ -1172,7 +1230,17 @@ app.all("/mcp", bearerAuthMiddleware(), async (c) => {
|
|
|
1172
1230
|
// biome-ignore lint/suspicious/noExplicitAny: Convex string API
|
|
1173
1231
|
return convex.query(functionName, args);
|
|
1174
1232
|
};
|
|
1175
|
-
|
|
1233
|
+
// Day-165-parity — thread the caller identity into the ui-resource
|
|
1234
|
+
// primitives the same way tools.ts does (mirrors the
|
|
1235
|
+
// master/callerIdentities computation at tools.ts:5712/5808/5934), so
|
|
1236
|
+
// briefingNotes:get/list resolve visibility inside Convex instead of
|
|
1237
|
+
// falling into the `callerIdentities === undefined` legacy-open branch.
|
|
1238
|
+
const master = oauthCtx === undefined || isMasterScope(oauthCtx);
|
|
1239
|
+
const callerIdentities = master ? undefined : oauthCtx.fromAllowList;
|
|
1240
|
+
return await readUiResource(uri.toString(), fetchConvex, {
|
|
1241
|
+
master,
|
|
1242
|
+
callerIdentities,
|
|
1243
|
+
});
|
|
1176
1244
|
});
|
|
1177
1245
|
const transport = new WebStandardStreamableHTTPServerTransport();
|
|
1178
1246
|
await server.connect(transport);
|
package/dist/server.d.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* VantagePeers MCP Server — stdio transport (Self-host / local Claude Code path).
|
|
4
4
|
*
|
|
5
|
-
* Thin bootstrap: resolves CONVEX_URL, instantiates McpServer +
|
|
6
|
-
* delegates ALL tool registration
|
|
5
|
+
* Thin bootstrap: resolves CONVEX_URL, instantiates McpServer + a
|
|
6
|
+
* service-account-identified Convex client, delegates ALL tool registration
|
|
7
|
+
* to the shared `registerTools(server, convex)`
|
|
7
8
|
* surface in src/tools.ts. This guarantees stdio and HTTP transports expose the
|
|
8
9
|
* same tool set (parity locked by src/__tests__/stdio-http-parity.test.ts).
|
|
9
10
|
*
|
package/dist/server.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* VantagePeers MCP Server — stdio transport (Self-host / local Claude Code path).
|
|
4
4
|
*
|
|
5
|
-
* Thin bootstrap: resolves CONVEX_URL, instantiates McpServer +
|
|
6
|
-
* delegates ALL tool registration
|
|
5
|
+
* Thin bootstrap: resolves CONVEX_URL, instantiates McpServer + a
|
|
6
|
+
* service-account-identified Convex client, delegates ALL tool registration
|
|
7
|
+
* to the shared `registerTools(server, convex)`
|
|
7
8
|
* surface in src/tools.ts. This guarantees stdio and HTTP transports expose the
|
|
8
9
|
* same tool set (parity locked by src/__tests__/stdio-http-parity.test.ts).
|
|
9
10
|
*
|
|
@@ -19,10 +20,23 @@
|
|
|
19
20
|
*/
|
|
20
21
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
21
22
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { resolve } from "node:path";
|
|
25
|
+
import { createServiceAccountConvexClient } from "./src/authenticatedConvexClient.js";
|
|
26
|
+
import { LOCAL_STDIO_TRUST_CTX } from "./src/auth.js";
|
|
25
27
|
import { registerTools } from "./src/tools.js";
|
|
28
|
+
// Advertised version is derived from the manifest, same pattern as
|
|
29
|
+
// server-http.ts, so both transports report identical values in source and
|
|
30
|
+
// dist mode.
|
|
31
|
+
let pkg;
|
|
32
|
+
try {
|
|
33
|
+
// Source mode: server.ts → ./package.json = mcp-server/package.json
|
|
34
|
+
pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8"));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Dist mode: dist/server.js → ../package.json = mcp-server/package.json
|
|
38
|
+
pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
39
|
+
}
|
|
26
40
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
27
41
|
// Bootstrap: resolve CONVEX_URL from env or .env.local
|
|
28
42
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -54,15 +68,20 @@ function loadConvexUrl() {
|
|
|
54
68
|
// Server setup
|
|
55
69
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
56
70
|
const convexUrl = loadConvexUrl();
|
|
57
|
-
|
|
71
|
+
// Grants the stdio path master, via the service-account carve-out —
|
|
72
|
+
// the local-trust posture claimed below, now backed by an actual identity.
|
|
73
|
+
const convex = createServiceAccountConvexClient(convexUrl);
|
|
58
74
|
const server = new McpServer({
|
|
59
75
|
name: "vantage-peers",
|
|
60
|
-
version:
|
|
76
|
+
version: pkg.version,
|
|
61
77
|
});
|
|
62
|
-
// stdio
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
registerTools
|
|
78
|
+
// stdio runs on the operator's own machine against their own CONVEX_URL, so it
|
|
79
|
+
// is trusted with full LOCAL authority. That authority is now PRESENTED as an
|
|
80
|
+
// explicit, named context (LOCAL_STDIO_TRUST_CTX) rather than inferred from an
|
|
81
|
+
// absent oauthCtx — the scope guards in registerTools refuse on undefined, so
|
|
82
|
+
// the missing-argument path is no longer a max-authority backdoor
|
|
83
|
+
// (.claude/rules/one-identity-layer.md clause 3).
|
|
84
|
+
registerTools(server, convex, LOCAL_STDIO_TRUST_CTX);
|
|
66
85
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
86
|
// Start server on stdio transport
|
|
68
87
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/dist/src/auth.d.ts
CHANGED
|
@@ -50,6 +50,20 @@ export type OAuthContext = {
|
|
|
50
50
|
* there for why that is fail-closed-safe.
|
|
51
51
|
*/
|
|
52
52
|
clerkJwt?: string;
|
|
53
|
+
/**
|
|
54
|
+
* SHA-256 hex of the OAuth access token presented on this request —
|
|
55
|
+
* set ONLY on auth path (2) after oauth:getAccessTokenByHash hits.
|
|
56
|
+
* The roster query derives the organisation from the token ROW keyed
|
|
57
|
+
* by this hash. It is a credential locator, not an organisation id.
|
|
58
|
+
*/
|
|
59
|
+
accessTokenHash?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Organisation slug snapshotted onto the access-token row at mint.
|
|
62
|
+
* Presence selects the token-derived roster path; absence on a
|
|
63
|
+
* non-Clerk caller is the #1215 refuse. Never passed as a Convex
|
|
64
|
+
* query argument.
|
|
65
|
+
*/
|
|
66
|
+
clerkOrgSlug?: string;
|
|
53
67
|
};
|
|
54
68
|
declare module "hono" {
|
|
55
69
|
interface ContextVariableMap {
|
|
@@ -57,6 +71,22 @@ declare module "hono" {
|
|
|
57
71
|
oauthContext: OAuthContext;
|
|
58
72
|
}
|
|
59
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Local-machine-trust context for the stdio transport (server.ts).
|
|
76
|
+
*
|
|
77
|
+
* The stdio server runs on the OPERATOR'S OWN machine (Self-host / local Claude
|
|
78
|
+
* Code via `npx vantage-peers-mcp`) against a CONVEX_URL the operator already
|
|
79
|
+
* holds. There is no network-facing bearer to scope on this transport, so the
|
|
80
|
+
* caller is trusted with full local authority.
|
|
81
|
+
*
|
|
82
|
+
* The point of naming it: this authority is now PRESENT and greppable, not
|
|
83
|
+
* inferred from an ABSENT oauthContext. server.ts passes it EXPLICITLY (see
|
|
84
|
+
* `.claude/rules/one-identity-layer.md` clause 3 — "a right is presented, never
|
|
85
|
+
* inferred from an absence"). Every guard below now REFUSES on undefined; the
|
|
86
|
+
* stdio full-access path keeps working only because this named context is
|
|
87
|
+
* handed in, never because a missing argument is read as master.
|
|
88
|
+
*/
|
|
89
|
+
export declare const LOCAL_STDIO_TRUST_CTX: OAuthContext;
|
|
60
90
|
export declare function internalClient(): ConvexHttpClient;
|
|
61
91
|
export declare function _setInternalClientForTest(client: ConvexHttpClient | null): void;
|
|
62
92
|
export declare function sha256Hex(input: string): Promise<string>;
|
|
@@ -71,26 +101,66 @@ export declare function sha256Base64Url(input: string): Promise<string>;
|
|
|
71
101
|
*
|
|
72
102
|
* Delegates the actual master/wildcard decision to
|
|
73
103
|
* `@vantageos/cloud-identity`'s `isMasterScope` (0.3.0+) via `toPackageOAuthCtx`
|
|
74
|
-
* above — this repo no longer reimplements that check locally. `undefined`
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
104
|
+
* above — this repo no longer reimplements that check locally. `undefined`
|
|
105
|
+
* returns FALSE here (absence is never master): callers pass an optional
|
|
106
|
+
* `OAuthContext`, and the package's own guard requires a non-null `OAuthCtx`
|
|
107
|
+
* and throws otherwise (0.3.0's "never grant by absence" contract), so the
|
|
108
|
+
* undefined check must happen before delegating, not be silently absorbed by
|
|
109
|
+
* the package call. Note the direction: undefined → NOT master (this fn) AND
|
|
110
|
+
* undefined → REFUSE (the check* predicates above); never undefined → grant.
|
|
80
111
|
*/
|
|
81
112
|
export declare function isMasterScope(ctx: OAuthContext | undefined): boolean;
|
|
82
113
|
/**
|
|
83
114
|
* Checks that `from` is allowed by the current OAuth context.
|
|
84
115
|
* Returns null when allowed, an error message string otherwise.
|
|
85
116
|
*
|
|
86
|
-
* `ctx`
|
|
87
|
-
*
|
|
88
|
-
* (master, OAuth, Clerk, DCR, legacy mcpTenants
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
117
|
+
* A missing `ctx` REFUSES (returns the refusal string), it never passes.
|
|
118
|
+
* Every real auth path sets an oauthContext — the HTTP transport via
|
|
119
|
+
* bearerAuthMiddleware (master, OAuth, Clerk, DCR, and the legacy mcpTenants
|
|
120
|
+
* bearer path, which resolves to a deny-by-default "legacy-tenant-generic"
|
|
121
|
+
* scope), and the stdio transport via the explicit LOCAL_STDIO_TRUST_CTX
|
|
122
|
+
* server.ts hands to registerTools. So a `!ctx` here is a misconfiguration,
|
|
123
|
+
* and it fails closed rather than granting max authority (clause 3,
|
|
124
|
+
* `.claude/rules/one-identity-layer.md`).
|
|
92
125
|
*/
|
|
93
126
|
export declare function checkFromAllowed(ctx: OAuthContext | undefined, from: string): string | null;
|
|
127
|
+
/**
|
|
128
|
+
* Answers the DELEGATION question — is `assignedTo` a member of the CALLER'S
|
|
129
|
+
* own organisation? — as distinct from `checkFromAllowed`, which answers a
|
|
130
|
+
* different question (may this client SPEAK AS `from`). Conflating the two
|
|
131
|
+
* meant every non-master client could only delegate to the one identity in
|
|
132
|
+
* its own `fromAllowList`, making cross-station dispatch impossible for any
|
|
133
|
+
* non-master client (Day-... delegation-same-org-predicate incident).
|
|
134
|
+
*
|
|
135
|
+
* Rules:
|
|
136
|
+
* - master scope (isMasterScope) → allowed (null), no roster read needed.
|
|
137
|
+
* - non-master AND `ctx.clerkJwt` present (the Clerk-team org-scoped path,
|
|
138
|
+
* auth.ts case 2.5, ~line 507) → `getOrgRoster` is called and resolves
|
|
139
|
+
* via Convex `withOrgScope`/`client_org_mapping`, forwarding THIS
|
|
140
|
+
* caller's own verified Clerk JWT (`selectConvexClientForRequest`,
|
|
141
|
+
* authenticatedConvexClient.ts). This is a genuine per-caller org lookup.
|
|
142
|
+
* Allowed iff `assignedTo` is in that roster, OR the roster itself is
|
|
143
|
+
* `["*"]` — a CALLER org whose `client_org_mapping.allowedOrchestrators`
|
|
144
|
+
* is itself the wildcard (a genuinely open org, same semantics as
|
|
145
|
+
* `isMaster = allowedOrchestrators.includes("*")` elsewhere in Convex,
|
|
146
|
+
* convex/lib/auth.ts).
|
|
147
|
+
* - non-master AND `ctx.clerkJwt` ABSENT AND the access token carries
|
|
148
|
+
* `accessTokenHash` + `clerkOrgSlug` → `getOrgRoster` MUST resolve via
|
|
149
|
+
* `orgRoster:getForAccessToken({ tokenHash })` (org derived inside
|
|
150
|
+
* Convex from that token row). Never `getMyOrgRoster` (service-account
|
|
151
|
+
* `["*"]` is ETA-M15).
|
|
152
|
+
* - non-master AND `ctx.clerkJwt` ABSENT AND no token org claim
|
|
153
|
+
* (DCR client-generic, legacy mcpTenants, unattached OAuth profiles)
|
|
154
|
+
* → REFUSE LOUDLY, `getOrgRoster` is NEVER called. ETA-M15: these paths
|
|
155
|
+
* route Convex through the MCP service-account; treating its `["*"]`
|
|
156
|
+
* as the caller org is the leak.
|
|
157
|
+
*
|
|
158
|
+
* Async because it performs a data read (clerkJwt path or token-hash path).
|
|
159
|
+
* `ctx` undefined REFUSES (returns the refusal string), matching the other
|
|
160
|
+
* check* predicates' fail-closed no-context convention — an absent identity
|
|
161
|
+
* is never allowed to delegate.
|
|
162
|
+
*/
|
|
163
|
+
export declare function checkDelegationAllowed(ctx: OAuthContext | undefined, assignedTo: string, getOrgRoster: () => Promise<string[]>): Promise<string | null>;
|
|
94
164
|
/**
|
|
95
165
|
* Checks namespace against prefix list. A prefix of "*" means any namespace.
|
|
96
166
|
* Otherwise the target namespace must start with one of the prefixes.
|