feat(plugin): resolve session project from git remote, not a pinned project_id
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user