feat(plugin): resolve session project from git remote, not a pinned project_id
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 1m0s

The SessionStart hook asked for a project_id via plugin userConfig, which pins
one install to a single project — wrong for an operator working across many
repos/projects. Resolve the active project server-side from the working repo's
git remote instead (a stable identifier, not a dir-name guess).

- repo_bindings table (migration 0064) + RepoBinding model: (user, repo_key) ->
  project, FKs CASCADE.
- services/repo_bindings: normalize_repo_key collapses ssh/https/scp/creds/port/
  .git to host/owner/repo; resolve/set/list/delete.
- GET /api/plugin/context takes ?repo=<remote>; unbound repo -> a "bind this
  repo" hint with a ready bind_repo() call. project_id kept as manual override.
- MCP tools: bind_repo / list_repo_bindings / unbind_repo.
- Hook sends ?repo=$(git remote get-url origin) URL-encoded; all project_id
  handling removed. plugin.json drops the project_id userConfig (0.1.2 -> 0.1.3).
- Tests: normalize equivalence classes + unbound-hint rendering.

Refs task 755 (Scribe-as-plugin push channel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 01:33:58 -04:00
parent 6cc47c7222
commit 8fe571e175
12 changed files with 410 additions and 19 deletions
+2 -1
View File
@@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
entities, events, milestones, notes, processes, projects, recent, rulebooks, search, tags, tasks, trash,
entities, events, milestones, notes, processes, projects, recent, repos, rulebooks, search, tags, tasks, trash,
)
@@ -20,6 +20,7 @@ def register_all(mcp) -> None:
tags.register(mcp)
recent.register(mcp)
entities.register(mcp)
repos.register(mcp)
processes.register(mcp)
rulebooks.register(mcp)
trash.register(mcp)
+68
View File
@@ -0,0 +1,68 @@
"""Repo -> project binding MCP tools.
Let the operator map the working git repository to a Scribe project so the
SessionStart hook auto-loads that project's context — without pinning a project
id in plugin config (which would force a single project for every repo). Run
`bind_repo` once per repo; the binding is keyed on the normalized git remote, so
it survives re-clones and ssh/https URL differences.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import projects as projects_svc
from scribe.services import repo_bindings as repo_bindings_svc
async def bind_repo(repo_url: str, project_id: int) -> dict:
"""Bind a git repository to a Scribe project for session-start context.
After this, any session started in that repo auto-loads the project's
context (the SessionStart hook sends the repo's remote; the server resolves
it here). Idempotent — re-binding the same repo updates the target project.
Args:
repo_url: the repo's git remote (e.g. the output of
`git remote get-url origin` — ssh or https form, both work).
project_id: the Scribe project this repo represents.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id)
return {
"repo_key": binding.repo_key,
"project_id": binding.project_id,
"project_title": project.title,
"message": f"Bound `{binding.repo_key}` -> {project.title} (id {project.id}).",
}
async def list_repo_bindings() -> dict:
"""List every repo -> project binding for the current user."""
uid = current_user_id()
bindings = await repo_bindings_svc.list_bindings(uid)
return {"bindings": [b.to_dict() for b in bindings]}
async def unbind_repo(repo_url: str) -> dict:
"""Remove a repo's binding. Sessions there fall back to standing rules only.
Args:
repo_url: the repo's git remote (same form used to bind it).
"""
uid = current_user_id()
removed = await repo_bindings_svc.delete_binding(uid, repo_url)
key = repo_bindings_svc.normalize_repo_key(repo_url)
return {
"removed": removed,
"repo_key": key,
"message": (
f"Unbound `{key}`." if removed else f"No binding found for `{key}`."
),
}
def register(mcp) -> None:
for fn in (bind_repo, list_repo_bindings, unbind_repo):
mcp.tool(name=fn.__name__)(fn)