diff --git a/docs/mcp-instance-runtime.md b/docs/mcp-instance-runtime.md index e86e81d8..bdb4e0b9 100644 --- a/docs/mcp-instance-runtime.md +++ b/docs/mcp-instance-runtime.md @@ -107,7 +107,7 @@ That response means the instance runtime path is working and correctly refusing ## MCP Tool Groups -The server currently has 136 tool registrations across `workers/mcp/src`. +The server currently has 137 tool registrations across `workers/mcp/src`. Some are capability-gated and appear only for users with matching agent surfaces, so `tools/list` on your own connection is the authoritative surface — 19 of those registrations are gated. The `/health` marker reports the same total from @@ -179,7 +179,7 @@ Status: active Verified live: -- MCP exposed its tool set (26 at the time of this 2026-06-11 record; 136 tool registrations in source today — see MCP Tool Groups above). +- MCP exposed its tool set (26 at the time of this 2026-06-11 record; 137 tool registrations in source today — see MCP Tool Groups above). - Required instance tools were present. - `subscribe_agent` returned the existing active instance. - `add_instance_knowledge` saved a document to that private instance. diff --git a/platform-docs/mcp.md b/platform-docs/mcp.md index 0efd1e79..2db76d2a 100644 --- a/platform-docs/mcp.md +++ b/platform-docs/mcp.md @@ -107,7 +107,7 @@ Confirm before destructive actions. ## What `initialize` Answers -- `serverInfo.version`: `0.1.6` +- `serverInfo.version`: `0.1.7` That is the same value the published MCP-registry manifest (`server.json`) carries, and both are read from one constant — `MCP_SERVER_VERSION` in `workers/mcp/src/server-version.ts` — @@ -196,7 +196,7 @@ The two published hints are **derived, not hand-maintained per tool**. `workers/mcp/src/tool-metadata.ts` classifies every tool `read` / `write` / `runtime` / `destructive` in one table, and `annotationsFor()` maps that classification onto the two hints. The classification is then derived **back out of the handlers** by `index.test.ts`, -which drives all 136 tools under two different scope sets and reads the required scope out +which drives all 137 tools under two different scope sets and reads the required scope out of each refusal — so a tool announced read-only that enforces a write gate fails the build rather than reaching a host. `conformance.test.ts` asserts the same thing against a real `tools/list` response. @@ -318,7 +318,7 @@ More recipes, with real argument names, are in ## Tool Surface -The server registers **136 tools**. 117 are always present. The remaining 19 are gated to +The server registers **137 tools**. 118 are always present. The remaining 19 are gated to the console surfaces of the connected user's own subscribed agents, so the surface is per-connection: diff --git a/server.json b/server.json index a515c05b..8ed07e48 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.ProAgentStore/platform", "title": "ProAgentStore", "description": "MCP-first control plane for ProAgentStore agents and private instances.", - "version": "0.1.6", + "version": "0.1.7", "repository": { "url": "https://github.com/ProAgentStore/platform", "source": "github" diff --git a/store/.well-known/mcp-server.json b/store/.well-known/mcp-server.json index a515c05b..8ed07e48 100644 --- a/store/.well-known/mcp-server.json +++ b/store/.well-known/mcp-server.json @@ -3,7 +3,7 @@ "name": "io.github.ProAgentStore/platform", "title": "ProAgentStore", "description": "MCP-first control plane for ProAgentStore agents and private instances.", - "version": "0.1.6", + "version": "0.1.7", "repository": { "url": "https://github.com/ProAgentStore/platform", "source": "github" diff --git a/store/about/index.html b/store/about/index.html index feee5a75..1c9eb34c 100644 --- a/store/about/index.html +++ b/store/about/index.html @@ -176,7 +176,7 @@

Data isolation

Developer tools

CLInpm i -g @proagentstore/cli then pags up: one runner serves ALL your agents over a WebSocket relay — no tunnel binary, no config. Plus pags login, pags init, pags check, pags publish.

-

MCP Server — manage agents from Claude Code, Cursor, or VS Code. 136 tools across creator operations, public trials, private instance runtime, coding, and error observability (list_errors).

+

MCP Server — manage agents from Claude Code, Cursor, or VS Code. 137 tools across creator operations, public trials, private instance runtime, coding, and error observability (list_errors).

Skills — install the same ProAgentStore MCP workflow in Codex and Claude Code from the skills catalog.

Webhook ingestion — Zapier, Make, n8n can POST documents directly into an instance's knowledge base.

API key vault — store your OpenAI, Anthropic, Google AI keys once. Encrypted AES-256-GCM. Agents access via proxy.

diff --git a/store/llms-full.txt b/store/llms-full.txt index 9c6e7f50..663087d9 100644 --- a/store/llms-full.txt +++ b/store/llms-full.txt @@ -156,7 +156,7 @@ That response means the private instance path is working and correctly refusing ## Main MCP Tool Groups -The server registers 136 tools; 117 are always present and 19 are gated to the `apply`, `repo`, and `coding` surfaces. The lists below are the commonly used subset, not the full surface — call `tools/list` for the authoritative set, and see the tool table in `workers/mcp/README.md` for scopes, `dry_run` support, and confirmation values. +The server registers 137 tools; 118 are always present and 19 are gated to the `apply`, `repo`, and `coding` surfaces. The lists below are the commonly used subset, not the full surface — call `tools/list` for the authoritative set, and see the tool table in `workers/mcp/README.md` for scopes, `dry_run` support, and confirmation values. Creator tools: diff --git a/store/openapi.yaml b/store/openapi.yaml index b4884870..d7ed8b22 100644 --- a/store/openapi.yaml +++ b/store/openapi.yaml @@ -1068,6 +1068,49 @@ paths: schema: $ref: "#/components/schemas/Error" + /v1/auth/me/account: + get: + summary: Get current account identity + description: > + Which account this session is connected as — id, login, sign-in provider + (github/google) and label, email (surfaced only for Google, whose sign-in + stores the address; a GitHub login is a username, returned as `login`), roles, + account createdAt, and this token's tokenExpiry. Narrower than GET /v1/auth/me + (which is the profile shape); exists so an MCP `whoami` can name the identity. + Gated by the session Bearer only — the row returned is always the caller's own. + operationId: getMeAccount + tags: [Auth] + responses: + "200": + description: Account identity + content: + application/json: + schema: + type: object + properties: + id: { type: string } + login: { type: string } + provider: { type: string, enum: [github, google] } + providerLabel: { type: string } + email: { type: string, nullable: true } + roles: + type: array + items: { type: string } + createdAt: { type: string } + tokenExpiry: { type: string, format: date-time } + "401": + description: Not authenticated or token expired + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: User not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + # ── Agents ──────────────────────────────────────────────────────────────── /v1/agents/my/agents: diff --git a/workers/api/src/routes/auth.integration.test.ts b/workers/api/src/routes/auth.integration.test.ts index 52ed5273..ea2aaf9c 100644 --- a/workers/api/src/routes/auth.integration.test.ts +++ b/workers/api/src/routes/auth.integration.test.ts @@ -46,6 +46,17 @@ function buildApp(opts: { seedRoles?: string; env?: Partial } = {}) { const u = users.find((x) => x.id === args[0]); return u ? { roles: u.roles ?? (opts.seedRoles ?? null), github_login: u.github_login } : null; } + // The /me/account identity read — distinguished from /me by selecting created_at. + if (sql.includes("created_at") && sql.includes("SELECT id, github_login")) { + const u = users.find((x) => x.id === args[0]); + if (!u) return null; + return { + id: u.id, + github_login: u.github_login, + roles: u.roles ?? '["user"]', + created_at: "2026-01-01T00:00:00Z", + }; + } if (sql.startsWith("SELECT id, github_login")) { const u = users.find((x) => x.id === args[0]); if (!u) return null; @@ -397,3 +408,51 @@ describe("GET /v1/auth/me + PUT /v1/auth/me (integration)", () => { expect(upd!.args).toContain("https://me.dev"); }); }); + +describe("GET /v1/auth/me/account (integration)", () => { + const tokenFor = (uid: string, roles: string[] = ["user"]) => signSession(uid, SECRET, { roles }); + + it("401s without a bearer token", async () => { + const { app, env } = buildApp(); + const res = await app.request("/v1/auth/me/account", {}, env); + expect(res.status).toBe(401); + }); + + it("404s a token whose user row doesn't exist", async () => { + const { app, env } = buildApp(); + const res = await app.request("/v1/auth/me/account", { headers: { Authorization: `Bearer ${await tokenFor("ghost")}` } }, env); + expect(res.status).toBe(404); + }); + + it("derives the github provider + surfaces no email for a bare-numeric uid", async () => { + const { app, env, users } = buildApp(); + users.push({ id: "12345", github_login: "octo", github_name: "Octo", avatar_url: "https://a", roles: '["user","creator"]' }); + const token = await tokenFor("12345", ["user", "creator"]); + const res = await app.request("/v1/auth/me/account", { headers: { Authorization: `Bearer ${token}` } }, env); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.id).toBe("12345"); + expect(body.login).toBe("octo"); + expect(body.provider).toBe("github"); + expect(body.providerLabel).toBe("GitHub"); + // A GitHub login is a username, not an address — never surfaced as email. + expect(body.email).toBeNull(); + expect(body.roles).toEqual(["user", "creator"]); + expect(body.createdAt).toBe("2026-01-01T00:00:00Z"); + // tokenExpiry is the session's own exp, rendered ISO — parseable and in the future. + expect(Number.isNaN(Date.parse(body.tokenExpiry as string))).toBe(false); + expect(Date.parse(body.tokenExpiry as string)).toBeGreaterThan(Date.now()); + }); + + it("derives the google provider + surfaces the stored email for a google: uid", async () => { + const { app, env, users } = buildApp(); + users.push({ id: "google:99", github_login: "user@example.com", github_name: "User", avatar_url: "https://a", roles: '["user"]' }); + const res = await app.request("/v1/auth/me/account", { headers: { Authorization: `Bearer ${await tokenFor("google:99")}` } }, env); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.provider).toBe("google"); + expect(body.providerLabel).toBe("Google"); + expect(body.email).toBe("user@example.com"); + expect(body.login).toBe("user@example.com"); + }); +}); diff --git a/workers/api/src/routes/auth.ts b/workers/api/src/routes/auth.ts index 2d4fe014..51948d1d 100644 --- a/workers/api/src/routes/auth.ts +++ b/workers/api/src/routes/auth.ts @@ -569,3 +569,45 @@ authRoutes.get("/me", async (c) => { boardConfig: parseJsonOrNull(row.board_config), }); }); + +/** + * GET /v1/auth/me/account — "which account am I connected as?" + * + * The gap this closes (mirrors ProAppStore #136): over the MCP connector a caller holds + * a session but has no tool that names the identity behind it. `/me` above is the SDK-ish + * profile shape (name/avatar/subscription/board); this is the narrower identity answer — + * who, via which provider, since when, and how long this token is still good for. + * + * Plain `requireUser` is the only gate. Unlike ProAppStore, PAGS does NOT de-privilege an + * MCP-origin session to `['user']` and does NOT ship this shape into creator-controlled app + * JS (there is no SDK `User` contract carrying it), so the internal-token second gate PAS + * needed here would be net-new plumbing guarding a leak PAGS does not have. The row returned + * is always the caller's OWN — there is no lookup-by-id parameter to abuse. + * + * `provider` is derived from the uid shape the OAuth callbacks mint: Google uids are + * `google:` (see /google/callback), GitHub uids are the bare numeric id. `email` is only + * meaningful for Google, whose callback stores the address in `github_login`; a GitHub + * `github_login` is a username, not an address, so it is surfaced as `login`, never `email`. + */ +authRoutes.get("/me/account", async (c) => { + const session = await requireUser(c); + const row = await c.env.DB.prepare( + "SELECT id, github_login, roles, created_at FROM users WHERE id = ?1", + ) + .bind(session.uid) + .first<{ id: string; github_login: string; roles: string; created_at: string }>(); + if (!row) return c.json({ error: "User not found" }, 404); + + const isGoogle = session.uid.startsWith("google:"); + const roles = JSON.parse(row.roles || '["user"]') as string[]; + return c.json({ + id: row.id, + login: row.github_login, + provider: isGoogle ? "google" : "github", + providerLabel: isGoogle ? "Google" : "GitHub", + email: isGoogle ? row.github_login : null, + roles, + createdAt: row.created_at, + tokenExpiry: new Date(session.exp * 1000).toISOString(), + }); +}); diff --git a/workers/mcp/AGENTS.md b/workers/mcp/AGENTS.md index dbfc4664..4bf51e00 100644 --- a/workers/mcp/AGENTS.md +++ b/workers/mcp/AGENTS.md @@ -12,7 +12,7 @@ Connection methods and the full tool table: [`README.md`](./README.md). unless the user explicitly overrides this. 2. **Discover the surface before using it.** The tool list is versioned and - *per-connection*: of the 136 tool registrations, 19 are gated to the console surfaces + *per-connection*: of the 137 tool registrations, 19 are gated to the console surfaces of the agents the connected user actually subscribes to (`apply`, `repo`, `coding`). A tool you used last week may be absent today, and a tool present for one user is absent for another. Call `tools/list` first; never assume a name exists. diff --git a/workers/mcp/CLAUDE.md b/workers/mcp/CLAUDE.md index 82e03c6d..4919f260 100644 --- a/workers/mcp/CLAUDE.md +++ b/workers/mcp/CLAUDE.md @@ -71,7 +71,7 @@ src/ ├── settings.ts 11 tools — settings, name, instructions, model, translation, state ├── triggers.ts 5 tools — webhook / cron / connector-sync triggers ├── composition.ts 8 tools — supervision (#183), connections (#182), loops - ├── account.ts 7 tools — billing, usage, keys, email, profile, budget limits + ├── account.ts 8 tools — whoami, billing, usage, keys, email, profile, budget limits ├── connectors.ts 4 tools — connector status and folder grants ├── stats.ts 4 tools — declarative stats cards (creator schema + subscriber override) │ ── surface-gated ── @@ -80,18 +80,18 @@ src/ └── coding.ts system_status + coding_timeline (gated to surfaces:["coding"]) + 3 loop tools ``` -**136 tool registrations** (`.tool(` in the files above): 31 in `index.ts` — 10 of them -inside a `groups.has("coding")` block — 13 in `storage-tools.ts`, and 92 across -`instance-tools/`. 117 are always registered; 19 are surface-gated (apply=4, repo=3, coding=12). +**137 tool registrations** (`.tool(` in the files above): 31 in `index.ts` — 10 of them +inside a `groups.has("coding")` block — 13 in `storage-tools.ts`, and 93 across +`instance-tools/`. 118 are always registered; 19 are surface-gated (apply=4, repo=3, coding=12). Those three numbers ADD UP to the headline, and that is the point of stating them: 31 + 13 -+ 92 = 136. They said 88 until #602, which made the paragraph sum to 132 — a total the ++ 93 = 137. They said 88 until #602, which made the paragraph sum to 132 — a total the same sentence contradicted two clauses earlier. The per-file rows in the tree above are machine-checked against `.tool(` counts; this prose sum is not, so it is the half that rots. `base.ts` was 1871 lines and 67 of the 86 instance tools THEN REGISTERED until #305 — the file a tool landed in when nobody decided where it went, and the largest in the repo. That -86 is history and is not the current count (92); it is kept because it is what makes the +86 is history and is not the current count (93); it is kept because it is what makes the 1871 lines legible. The nine ungated groups above are that file split along the registration boundaries it already had; the blocks moved verbatim. @@ -100,7 +100,7 @@ Tests sit beside their modules: `index.test.ts`, `index-auth.test.ts`, `repo-tools.test.ts`, `safety.test.ts`, `storage-tools.test.ts`. `instance-tools/contract.test.ts` is the one to know about. It holds every tool registered -under `instance-tools/` — 92 of them — to a table of **group, scope, confirmation string, +under `instance-tools/` — 93 of them — to a table of **group, scope, confirmation string, dry-run behaviour and input fields** — and every value in that table is DERIVED by driving the registered handler (call it holding only `read`, then holding everything but `read`, and read the required scope out of the diff --git a/workers/mcp/README.md b/workers/mcp/README.md index c2428090..b3bf807c 100644 --- a/workers/mcp/README.md +++ b/workers/mcp/README.md @@ -126,7 +126,7 @@ implementation. ## Tools -**136 tool registrations.** 117 are always registered; 19 are gated to the console +**137 tool registrations.** 118 are always registered; 19 are gated to the console surfaces of the connected user's subscribed agents (`apply`, `repo`, `coding`), so a Repo Chat user never sees `apply_to_job`. @@ -367,6 +367,7 @@ immediately instead of a whole transcript. | `list_pipeline_runs` | Declarative-pipeline runs with counts | — | | | | `get_instance_pipeline` | Read back a single stored pipeline definition (with validity) | — | | | | `mcp_audit_log` | Recent MCP write/runtime/dry-run/denied events for this account | read | | | +| `whoami` | Which account you are connected as — id, login, sign-in provider, email (Google only), roles, createdAt, token expiry | — | | | | `billing_status` | Free vs Pro, paywall enforcement | — | | | | `usage_summary` | Token usage + estimated cost by agent/model/activity | — | | | | `keys_status` | Which providers have a BYOK key — **names only** | — | | | diff --git a/workers/mcp/src/instance-tools/account.ts b/workers/mcp/src/instance-tools/account.ts index f221aae2..8b4ad006 100644 --- a/workers/mcp/src/instance-tools/account.ts +++ b/workers/mcp/src/instance-tools/account.ts @@ -15,6 +15,20 @@ import type { InstanceToolsCtx } from "./shared.js"; export function registerAccountTools(server: McpServer, ctx: InstanceToolsCtx): void { const { env, tokenFor, safetyFor } = ctx; + server.tool( + "whoami", + "Which account this connection is signed in as: id, login, sign-in provider — the string `github` or `google` — plus a display label, email (only when signed in with Google — a GitHub login is a username, not an address, and comes back as `login`), roles, account createdAt, and this token's tokenExpiry. Answers 'who am I connected as?' — nothing here is a secret. For plan/billing use billing_status; for BYOK keys use keys_status.", + { + token: z.string().optional().describe("PAGS session token. Omit when connected with browser sign-in."), + }, + async ({ token }) => { + const sessionToken = tokenFor(token); + if (!sessionToken) return authRequired(); + const data = await authedCall("/v1/auth/me/account", sessionToken, {}, env); + return jsonText(data); + }, + ); + server.tool( "billing_status", "Read your billing/plan status (free vs Pro, whether the paywall is enforced, whether a billing account exists). Upgrades happen in the console (browser redirect).", diff --git a/workers/mcp/src/instance-tools/contract.test.ts b/workers/mcp/src/instance-tools/contract.test.ts index 96626898..e7efa996 100644 --- a/workers/mcp/src/instance-tools/contract.test.ts +++ b/workers/mcp/src/instance-tools/contract.test.ts @@ -316,6 +316,7 @@ const TABLE: Record = { // 64 KiB limit, so the inventory is paged. Still ungated — paging a read changes nothing // about what it may see. vector_stats: ["knowledge", "none", null, null, "instance_id,limit,offset,token"], + whoami: ["account", "none", null, null, "token"], write_instance_memory: ["knowledge", "write", null, "envelope", "content,dry_run,instance_id,key,token,type"], }; diff --git a/workers/mcp/src/server-version.ts b/workers/mcp/src/server-version.ts index c05a419f..cf9958b6 100644 --- a/workers/mcp/src/server-version.ts +++ b/workers/mcp/src/server-version.ts @@ -88,4 +88,4 @@ */ /** Advertised in `serverInfo.version`, and restated in `server.json` and `platform-docs/mcp.md`. */ -export const MCP_SERVER_VERSION = "0.1.6"; +export const MCP_SERVER_VERSION = "0.1.7"; diff --git a/workers/mcp/src/surface-lock.ts b/workers/mcp/src/surface-lock.ts index 4f55bad2..5926619f 100644 --- a/workers/mcp/src/surface-lock.ts +++ b/workers/mcp/src/surface-lock.ts @@ -116,4 +116,9 @@ export const SURFACE_LOCK: Record = { // through when `my_instances` became `{"instances":[…]}` under a frozen version, so the bump // is justified twice over here as it was for 0.1.5. Appended, never edited: 0.1.5 is published. "0.1.6": "sha256:bdf6eb2efd98c4df362bbc6762537ca8b687dc9a03ce21a519326a73c21b9e98", + // 0.1.7 (#672): `whoami` registered — a new tool NAME in the always-on `account` group, + // the first bullet of `MCP_SERVER_VERSION`'s list, so the served surface grew by one and + // this hash moves. It reads the new `GET /v1/auth/me/account` and is annotated `read` + // (`readOnlyHint: true`), matching its ungated gate. Appended, never edited: 0.1.6 is published. + "0.1.7": "sha256:5a0bca0af76e29fa7ac6dcd673d76a8ba7a32dc9c6d506f3aa7b2e30c99eade8", }; diff --git a/workers/mcp/src/tool-count.ts b/workers/mcp/src/tool-count.ts index fc7f255d..743bfc79 100644 --- a/workers/mcp/src/tool-count.ts +++ b/workers/mcp/src/tool-count.ts @@ -20,10 +20,10 @@ */ /** Every tool the server can register, with all surfaces gated on. */ -export const MCP_TOOL_COUNT = 136; +export const MCP_TOOL_COUNT = 137; /** Registered for every connection, whatever the user is subscribed to. */ -export const MCP_TOOL_ALWAYS_ON = 117; +export const MCP_TOOL_ALWAYS_ON = 118; /** Registered only when the user has an agent with the matching console surface * (`apply`, `repo`, `coding`) — so a Repo Chat user never sees `apply_to_job`. */ diff --git a/workers/mcp/src/tool-metadata.ts b/workers/mcp/src/tool-metadata.ts index d122ee9a..d406268d 100644 --- a/workers/mcp/src/tool-metadata.ts +++ b/workers/mcp/src/tool-metadata.ts @@ -167,6 +167,7 @@ export const TOOL_RISK: Record = { ticket_thread: "read", usage_summary: "read", vector_stats: "read", + whoami: "read", // ── write: adds to or updates PAGS state, and this server grants it on a default // connection. Announced `readOnlyHint: false, destructiveHint: false`. ── @@ -270,7 +271,7 @@ export const TOOL_RISK: Record = { /** How the surface splits. A ratchet in BOTH directions: silently losing a read-only * annotation is as much a regression as silently gaining one. */ export const MCP_RISK_COUNTS: Record = { - read: 67, + read: 68, write: 40, runtime: 15, destructive: 14,