Skip to content

fix(den): default MCP tokens to read scope#2280

Merged
src-opn merged 2 commits into
devfrom
mcp-01
Jun 15, 2026
Merged

fix(den): default MCP tokens to read scope#2280
src-opn merged 2 commits into
devfrom
mcp-01

Conversation

@src-opn

@src-opn src-opn commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • centralize Den MCP scope constants and defaults
  • default dynamic MCP clients and first-party MCP token minting to read-only scope
  • stop auto-upgrading MCP OAuth clients to both read/write scopes while preserving explicit desktop read/write requests
  • add regression coverage for MCP scope defaults and no-upgrade behavior

Validation

  • pnpm --filter @openwork-ee/den-api exec bun test test/mcp-scopes.test.ts test/mcp-auth.test.ts test/mcp-invoke-body.test.ts test/mcp-oauth-client-policy.test.ts test/mcp-token-lifetime.test.ts test/mcp-membership-revocation.test.ts → 23 pass, 0 fail
  • pnpm --filter @openwork-ee/den-api build → passed
  • pnpm --filter @openwork/app typecheck → passed
  • pnpm dev:web-local live validation after restoring local MySQL health:
    • protected-resource metadata advertises mcp:read and mcp:write
    • dynamic client registration defaults to openid profile email mcp:read
    • explicit mcp:read remains read-only; explicit mcp:write remains write-only
    • invalid public redirect URI is rejected
    • first-party MCP token mint defaults to ["mcp:read"]
    • explicit desktop MCP token mint returns ["mcp:read", "mcp:write"]
    • read-only MCP token can initialize MCP but write tool calls return insufficient_mcp_scope

Note

Live third-party OAuth code exchange exposed a separate existing Better Auth MySQL adapter issue where the authorization code row is consumed but /oauth2/token returns invalid_grant. The scope behavior up through registration, consent/code issuance, and first-party MCP enforcement is validated in this PR; that token-exchange bug is separate from this least-privilege change.

Review in cubic

@vercel

vercel Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openwork-app Ready Ready Preview, Comment Jun 15, 2026 5:32pm
openwork-den Ready Ready Preview, Comment Jun 15, 2026 5:32pm
openwork-den-worker-proxy Ready Ready Preview, Comment Jun 15, 2026 5:32pm
openwork-landing Ready Ready Preview, Comment, Open in v0 Jun 15, 2026 5:32pm

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 6 files

Re-trigger cubic

# Conflicts:
#	ee/apps/den-api/src/routes/auth/index.ts
@src-opn src-opn merged commit b74b0aa into dev Jun 15, 2026
10 checks passed
benjaminshafii added a commit to joi-fairshare/openwork that referenced this pull request Jul 3, 2026
Default token scopes are read-only since different-ai#2280, so the save step failed
with insufficient_mcp_scope. Mirrors the desktop client's mint (den.ts).
benjaminshafii added a commit that referenced this pull request Jul 3, 2026
)

* feat(den-db): memory bank schema, typeids, idempotent FULLTEXT index

Stage 1 of Memory Bank v0 (TASK-1).

- Register `memory`/`memctx` typeid prefixes in packages/utils [B5]
- Add `memory` + `memory_context` tables. No DB-level FK (den-db avoids FK
  constraints per PlanetScale/Vitess convention); the memory->context cascade is
  app-enforced in the Stage 2 delete handler, backed by an index on memory_id.
- Migration 0028 (tables + indexes) with a hand-appended CREATE FULLTEXT INDEX
  for the migrate path (Drizzle mysql-core has no FULLTEXT DSL, drizzle-orm#1495).
- ensureMemoryFulltextIndex / assertMemoryFulltextIndexExists + a bootstrap hook,
  so the FULLTEXT index is created idempotently on BOTH the fresh-install
  (drizzle-kit push + baseline, which drops migration-only indexes) and the
  migrate paths [B2].
- verify-memory-schema.ts: proof harness (schema, FULLTEXT on both paths, NL
  MATCH, typeid round-trip, explicit cascade, idempotency).
- docs/memory-bank-architecture.md (authoritative architecture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(den-db): address Stage 1 review findings (mig-0, arch-0/1/2, ts-0/1)

Zero-findings pass after a 5-lens review of the Stage 1 schema commit.

- mig-0: run the idempotent FULLTEXT ensure on ALL apply paths, not just
  bootstrap. `db:migrate` and `db:push` now chain `ensure-fulltext-indexes.ts`,
  so a `db:migrate`-only run on an already-baselined DB (the prod path in
  den-db-migrate.yml, and the local db:push dev path) still creates the index.
- arch-0: expose the proof harness as `db:verify-memory` (was only runnable via
  a raw node invocation, weakening its CI-gate claim).
- arch-1: single `ensureFulltextIndexes()` seam in fulltext.ts called by both
  apply paths, so they cannot drift; bootstrap log is now accurate.
- arch-2: extract createExecutor/Executor into scripts/db-executor.ts, shared by
  bootstrap.ts and the ensure runner (removes memory-scoped triplication).
- ts-0/int-0: roll back before best-effort cleanup in verify-memory-schema.ts so
  a mid-transaction failure cannot leak marker rows.
- ts-1: make isRecord file-private (dead export surface).

Re-proven against MySQL 8.4: bootstrap, db:push, and the db:migrate chain each
create the FULLTEXT index; second ensure is a no-op; db:verify-memory passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(den-api): user-scoped memory routes + Memory MCP tag (TASK-2, TASK-3)

Stage 2 of Memory Bank v0.

Routes (routes/memory): postMemory, getMemorySearch, getMemory (list),
deleteMemoryById — all owner-scoped on user_id as the PRIMARY predicate (NOT the
org-scoped worker pattern) [B4]:
- getMemoryByIdForUser + 404 (non-leaking) for ids the caller does not own.
- Flattened, mostly-optional save payload; shape encoded in the OpenAPI summary;
  server sets source and forces scope='user' (ignores any client scope) [B3].
- One-transaction save (memory + N context rows); input bounds on content/tags/
  contexts/query/limit.
- Bound MATCH(content) AGAINST(:q IN NATURAL LANGUAGE MODE) combined with user_id
  in a single and(); never sql.raw. Empty result set -> 200 (not an error).
- Explicit memory->context cascade in the delete handler (den-db has no DB FKs).
- Structured save/search/delete events (JSON log lines) for measurement/debugging.
Guarded with authenticatedRoute(); org id read from the session (works for the
MCP internal-principal path without re-resolving org membership).

MCP (TASK-3): "Memory" added to SAFE_INCLUDED_TAGS; routes tagged ["Memory"], so
the four capabilities surface via search_capabilities / execute_capability. The
operationIds are exactly postMemory/getMemorySearch/getMemory/deleteMemoryById
(never memory_save/memory_search).

Tests (bun): test/memory-routes.test.ts (operationIds, MCP allow+scopes, payload
bounds, scope/source not client-settable) and test/memory-ownership.test.ts — the
cross-user IDOR regression merge gate [B4] proving save -> Bob 404 -> Alice recall
-> cascade delete against the real app + MySQL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(den-api): address Stage 2 review findings (int-1/3, arch-1/2/conv-1, ts-1/2/3)

Zero-findings pass after a 5-lens review (security · perf · TS · integrity · arch).

- int-1/arch-2: save now sets created_at/updated_at explicitly to a single `now` and
  returns that same row via toMemoryResponse, so the 201 body is DB-authoritative
  (matches a later getMemory/getMemorySearch) — MySQL has no INSERT ... RETURNING.
- conv-1: one `const row = {...} satisfies typeof MemoryTable.$inferInsert` is the
  single source for both the insert and the response — they cannot drift.
- int-3: context rows share the same `now`, so a memory and its provenance carry one
  timestamp; memory.created_at is the app-clock ordering authority (documented).
- ts-1: POST response routed through toMemoryResponse (was an inline literal).
- ts-2: DELETE 204 uses emptyResponse(); ts-3: dropped dead schema exports.
- sec-1: comment — reads are user-scoped, intentionally org-spanning (§8).
- arch-1: comment — org id from the resolved session; desktop hydrates via /me/orgs.
- int-2: architecture §9 records account/org-deletion memory reaping as deferred pre-GA.

Accepted-in-writing (no code change): sec-2 (session ids already normalized → can't
throw), perf-1 (inherent MySQL fulltext post-filter), perf-2 (composite index deferred,
small v0 corpus), ts-4/arch-3 (intentional, required by the mapper).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(server): static search-first ## Memory Bank prompt (TASK-4)

Append a static, distinct `## Memory Bank` section to OPENWORK_AGENT_PROMPT
(separate from the credential-hygiene `## Memory` section), injected
unconditionally. Search-first [B1]: reach memory only by discovering a
capability via search_capabilities then running it via execute_capability —
never a bespoke memory_save/memory_search tool; it is NOT a local file. Covers
draft → human-confirm/edit → execute save, explicit lexical recall (no
auto-recall), a list/delete manage note, and no-secrets guidance (the only v0
plaintext-at-rest mitigation) extended to cited snippets.

Also corrects the capabilities-knowledge plugin, which previously told the agent
there was "no separate long-term memory store" — it now points at the Memory
Bank capability.

Test: openwork-runtime-config.test.ts asserts the section is present, distinct
from `## Memory`, names search_capabilities/execute_capability, contains neither
memory_save nor memory_search, and includes no-secrets guidance (4 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(evals): memory-save-recall flow — discover → save → recall (TASK-9)

Mirror mcp-search-capabilities.flow.mjs. Proves, on the live meta-MCP + a
signed-in desktop app, that the agent discovers the memory-save capability
search-first (no memory_save tool), execute_capability persists a scope='user'
memory, a FRESH mcp call recalls it by natural-language query (cross-session
lexical recall), and the desktop Memory panel shows it. Fails loudly on B1
(search-first) and B3 (flattened save body) regressions.

Proven end-to-end against the running stack: postMemory discovered search-first
(hasBody:true) → saved mem_… (scope forced user) → recalled via "Acme renewal
deal" (FULLTEXT score 0.18) → deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(den-api): execute_capability accepts stringified path/query args

MCP clients (LLMs) frequently pass `path`/`query` as a JSON-encoded string
rather than an object, which failed execute_capability input validation
("expected record, received string") — the same hazard normalizeToolBody
already guarded for the body. Loosen the path/query schema to accept a string
and normalize it via a new normalizeToolRecord (mirrors normalizeToolBody), so
stringified args like `"{\"q\":\"acme\"}"` work. Unblocks getMemorySearch recall
via the agent. General improvement — applies to every capability, not just memory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(den-api): save memory only to an org the caller is a member of (orgMemberRoute)

POST /v1/memory previously took org_id from session.activeOrganizationId with no
membership check, so a stale/forged active org (or MCP principal) could write a
memory tagged to an org the user isn't in. Switch the save guard from
authenticatedRoute() to orgMemberRoute(): resolveOrganizationContextMiddleware
resolves the active org AND verifies membership via getOrganizationContextForUser
(404 otherwise) on both the cookie/bearer and MCP-principal paths. org_id now comes
from the verified organizationContext.organization.id.

- 400 organization_unavailable → the middleware returns 404 organization_not_found
  for a non-member / no-active-org caller (before the handler runs).
- Reads (search/list) and delete stay authenticatedRoute() — user_id-scoped and
  cross-org by design (v0 recalls across all of the caller's orgs; org_id is only
  recorded for the future org-shared bank, §8).
- memory-ownership.test.ts seeds real org + membership rows (required by the gate)
  and adds a non-member save → 404 assertion.

Proven live via the meta-MCP (member save→recall still works) and by the ownership
integration test (5 pass: member 201, non-member 404, IDOR 404, cascade delete).
den-api tsc: 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(evals): mint the memory eval MCP token with mcp:write scope

Default token scopes are read-only since #2280, so the save step failed
with insufficient_mcp_scope. Mirrors the desktop client's mint (den.ts).

---------

Co-authored-by: jthorare <60298547+jthorare@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Benjamin Shafii <benjamin.shafii@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant