Skip to content

Commit 66ffdf3

Browse files
authored
Merge pull request #9872 from gitbutlerapp/kv-branch-52
Implement claude permissions mechanism over MCP
2 parents fe2a062 + 4f2e431 commit 66ffdf3

File tree

10 files changed

+292
-27
lines changed

10 files changed

+292
-27
lines changed

crates/but-claude/src/db.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,31 @@ impl TryFrom<crate::ClaudeMessage> for but_db::ClaudeMessage {
183183
})
184184
}
185185
}
186+
187+
impl TryFrom<but_db::ClaudePermissionRequest> for crate::ClaudePermissionRequest {
188+
type Error = anyhow::Error;
189+
fn try_from(value: but_db::ClaudePermissionRequest) -> Result<Self, Self::Error> {
190+
Ok(crate::ClaudePermissionRequest {
191+
id: value.id.to_string(),
192+
created_at: value.created_at,
193+
updated_at: value.updated_at,
194+
tool_name: value.tool_name,
195+
input: serde_json::from_str(&value.input)?,
196+
approved: value.approved,
197+
})
198+
}
199+
}
200+
201+
impl TryFrom<crate::ClaudePermissionRequest> for but_db::ClaudePermissionRequest {
202+
type Error = anyhow::Error;
203+
fn try_from(value: crate::ClaudePermissionRequest) -> Result<Self, Self::Error> {
204+
Ok(but_db::ClaudePermissionRequest {
205+
id: value.id,
206+
created_at: value.created_at,
207+
updated_at: value.updated_at,
208+
tool_name: value.tool_name,
209+
input: serde_json::to_string(&value.input)?,
210+
approved: value.approved,
211+
})
212+
}
213+
}

crates/but-claude/src/lib.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,20 @@ pub struct ClaudeSessionDetails {
6464
pub summary: Option<String>,
6565
pub last_prompt: Option<String>,
6666
}
67+
68+
/// Represents a request for permission to use a tool in the Claude MCP.
69+
#[derive(Serialize, Deserialize, Debug, Clone)]
70+
pub struct ClaudePermissionRequest {
71+
/// Maps to the tool_use_id from the MCP request
72+
pub id: String,
73+
/// When the requst was made.
74+
pub created_at: chrono::NaiveDateTime,
75+
/// When the request was updated.
76+
pub updated_at: chrono::NaiveDateTime,
77+
/// The tool for which permission is requested
78+
pub tool_name: String,
79+
/// The input for the tool
80+
pub input: serde_json::Value,
81+
/// The status of the request or None if not yet handled
82+
pub approved: Option<bool>,
83+
}

crates/but-claude/src/mcp.rs

Lines changed: 103 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,27 @@
1-
use std::sync::{Arc, Mutex};
1+
use std::{
2+
path::Path,
3+
sync::{Arc, Mutex},
4+
};
25

36
use anyhow::Result;
7+
use but_db::poll::ItemKind;
8+
use but_settings::AppSettings;
9+
use gitbutler_command_context::CommandContext;
10+
use gitbutler_project::Project;
411
use rmcp::{
512
Error as McpError, ServerHandler, ServiceExt,
613
model::{
714
CallToolResult, Content, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo,
815
},
916
schemars, tool,
1017
};
11-
use tracing_subscriber::{self, EnvFilter};
12-
13-
pub async fn start() -> Result<()> {
14-
tracing_subscriber::fmt()
15-
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::DEBUG.into()))
16-
.with_writer(std::io::stderr)
17-
.with_ansi(false)
18-
.init();
19-
20-
tracing::info!("Starting MCP server");
2118

19+
pub async fn start(repo_path: &Path) -> Result<()> {
20+
let project = Project::from_path(repo_path).expect("Failed to create project from path");
2221
let client_info = Arc::new(Mutex::new(None));
2322
let transport = (tokio::io::stdin(), tokio::io::stdout());
24-
let service = Mcp::default().serve(transport).await?;
23+
let server = Mcp { project };
24+
let service = server.serve(transport).await?;
2525
let info = service.peer_info();
2626
if let Ok(mut guard) = client_info.lock() {
2727
guard.replace(info.client_info.clone());
@@ -31,27 +31,105 @@ pub async fn start() -> Result<()> {
3131
}
3232

3333
#[derive(Debug, Clone, Default)]
34-
pub struct Mcp {}
34+
pub struct Mcp {
35+
project: Project,
36+
}
3537

3638
#[tool(tool_box)]
3739
impl Mcp {
38-
#[tool(description = "Permission check - approve if the input contains allow, otherwise deny.")]
40+
#[tool(description = "Permission check for tool calls")]
3941
pub fn approval_prompt(
4042
&self,
41-
#[tool(aggr)] request: PermissionRequest,
43+
#[tool(aggr)] request: McpPermissionRequest,
4244
) -> Result<CallToolResult, McpError> {
43-
let result = Ok(PermissionResponse {
44-
behavior: Behavior::Allow,
45+
let approved = self
46+
.approval_inner(request.clone().into(), std::time::Duration::from_secs(60))
47+
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
48+
49+
let result = Ok(McpPermissionResponse {
50+
behavior: if approved {
51+
Behavior::Allow
52+
} else {
53+
Behavior::Deny
54+
},
4555
updated_input: Some(request.input),
46-
message: None,
56+
message: if approved {
57+
None
58+
} else {
59+
Some("Rejected by user".to_string())
60+
},
4761
});
4862
result.map(|outcome| Ok(CallToolResult::success(vec![Content::json(outcome)?])))?
4963
}
64+
65+
fn approval_inner(
66+
&self,
67+
req: crate::ClaudePermissionRequest,
68+
timeout: std::time::Duration,
69+
) -> anyhow::Result<bool> {
70+
let ctx = &mut CommandContext::open(
71+
&self.project,
72+
AppSettings::load_from_default_path_creating()?,
73+
)?;
74+
// Create a record that will be seen by the user in the UI
75+
ctx.db()?
76+
.claude_permission_requests()
77+
.insert(req.clone().try_into()?)?;
78+
// Poll for user approval
79+
let rx = ctx.db()?.poll_changes(
80+
ItemKind::Actions
81+
| ItemKind::Workflows
82+
| ItemKind::Assignments
83+
| ItemKind::Rules
84+
| ItemKind::ClaudePermissionRequests,
85+
std::time::Duration::from_millis(500),
86+
)?;
87+
let mut approved_state = false;
88+
let start_time = std::time::Instant::now();
89+
for item in rx {
90+
if start_time.elapsed() > timeout {
91+
eprintln!("Timeout waiting for permission approval (60 seconds)");
92+
break;
93+
}
94+
match item {
95+
Ok(ItemKind::ClaudePermissionRequests) => {
96+
if let Some(updated) = ctx.db()?.claude_permission_requests().get(&req.id)? {
97+
if let Some(approved) = updated.approved {
98+
approved_state = approved;
99+
break;
100+
}
101+
} else {
102+
eprintln!("Permission request not found: {}", req.id);
103+
break;
104+
}
105+
}
106+
Ok(_) => continue, // Ignore other item kinds
107+
Err(e) => {
108+
eprintln!("Error polling for changes: {e}");
109+
break;
110+
}
111+
}
112+
}
113+
ctx.db()?.claude_permission_requests().delete(&req.id)?;
114+
Ok(approved_state)
115+
}
50116
}
51117

52-
#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
53-
#[serde(rename_all = "camelCase")]
54-
pub struct PermissionRequest {
118+
impl From<McpPermissionRequest> for crate::ClaudePermissionRequest {
119+
fn from(request: McpPermissionRequest) -> Self {
120+
crate::ClaudePermissionRequest {
121+
id: request.tool_use_id,
122+
created_at: chrono::Utc::now().naive_utc(),
123+
updated_at: chrono::Utc::now().naive_utc(),
124+
tool_name: request.tool_name,
125+
input: request.input,
126+
approved: None,
127+
}
128+
}
129+
}
130+
131+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
132+
pub struct McpPermissionRequest {
55133
#[schemars(description = "The name of the tool requesting permission")]
56134
tool_name: String,
57135
#[schemars(description = "The input for the tool")]
@@ -60,17 +138,17 @@ pub struct PermissionRequest {
60138
tool_use_id: String,
61139
}
62140

63-
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, strum::Display)]
141+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64142
pub enum Behavior {
65-
#[strum(serialize = "allow")]
143+
#[serde(rename = "allow")]
66144
Allow,
67-
#[strum(serialize = "deny")]
145+
#[serde(rename = "deny")]
68146
Deny,
69147
}
70148

71149
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
72150
#[serde(rename_all = "camelCase")]
73-
pub struct PermissionResponse {
151+
pub struct McpPermissionResponse {
74152
behavior: Behavior,
75153
updated_input: Option<serde_json::Value>,
76154
message: Option<String>,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- This file should undo anything in `up.sql`
2+
DROP TABLE IF EXISTS `claude_permission_requests`;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- Your SQL goes here
2+
CREATE TABLE `claude_permission_requests`(
3+
`id` TEXT NOT NULL PRIMARY KEY,
4+
`created_at` TIMESTAMP NOT NULL,
5+
`updated_at` TIMESTAMP NOT NULL,
6+
`tool_name` TEXT NOT NULL,
7+
`input` TEXT NOT NULL,
8+
`approved` BOOL
9+
);

crates/but-db/src/claude.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ pub struct ClaudeMessage {
4343
pub content: String,
4444
}
4545

46+
#[derive(
47+
Debug, Clone, PartialEq, Serialize, Deserialize, Queryable, Selectable, Insertable, Identifiable,
48+
)]
49+
#[diesel(table_name = crate::schema::claude_permission_requests)]
50+
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
51+
pub struct ClaudePermissionRequest {
52+
pub id: String,
53+
pub created_at: chrono::NaiveDateTime,
54+
pub updated_at: chrono::NaiveDateTime,
55+
pub tool_name: String,
56+
pub input: String,
57+
pub approved: Option<bool>,
58+
}
59+
4660
impl DbHandle {
4761
pub fn claude_sessions(&mut self) -> ClaudeSessionsHandle {
4862
ClaudeSessionsHandle { db: self }
@@ -51,6 +65,11 @@ impl DbHandle {
5165
pub fn claude_messages(&mut self) -> ClaudeMessagesHandle {
5266
ClaudeMessagesHandle { db: self }
5367
}
68+
69+
pub fn claude_permission_requests(&mut self) -> ClaudePermissionRequestsHandle {
70+
ClaudePermissionRequestsHandle { db: self }
71+
}
72+
5473
pub fn delete_session_and_messages(
5574
&mut self,
5675
session_id: &str,
@@ -79,6 +98,62 @@ pub struct ClaudeMessagesHandle<'a> {
7998
db: &'a mut DbHandle,
8099
}
81100

101+
pub struct ClaudePermissionRequestsHandle<'a> {
102+
db: &'a mut DbHandle,
103+
}
104+
105+
impl ClaudePermissionRequestsHandle<'_> {
106+
pub fn insert(
107+
&mut self,
108+
request: ClaudePermissionRequest,
109+
) -> Result<(), diesel::result::Error> {
110+
diesel::insert_into(crate::schema::claude_permission_requests::table)
111+
.values(request)
112+
.execute(&mut self.db.conn)?;
113+
Ok(())
114+
}
115+
116+
pub fn set_approval(&mut self, id: &str, approved: bool) -> Result<(), diesel::result::Error> {
117+
diesel::update(
118+
crate::schema::claude_permission_requests::table
119+
.filter(crate::schema::claude_permission_requests::id.eq(id)),
120+
)
121+
.set((
122+
crate::schema::claude_permission_requests::approved.eq(approved),
123+
crate::schema::claude_permission_requests::updated_at
124+
.eq(chrono::Local::now().naive_local()),
125+
))
126+
.execute(&mut self.db.conn)?;
127+
Ok(())
128+
}
129+
130+
pub fn get(
131+
&mut self,
132+
id: &str,
133+
) -> Result<Option<ClaudePermissionRequest>, diesel::result::Error> {
134+
let request = crate::schema::claude_permission_requests::table
135+
.filter(crate::schema::claude_permission_requests::id.eq(id))
136+
.first::<ClaudePermissionRequest>(&mut self.db.conn)
137+
.optional()?;
138+
Ok(request)
139+
}
140+
141+
pub fn delete(&mut self, id: &str) -> Result<(), diesel::result::Error> {
142+
diesel::delete(
143+
crate::schema::claude_permission_requests::table
144+
.filter(crate::schema::claude_permission_requests::id.eq(id)),
145+
)
146+
.execute(&mut self.db.conn)?;
147+
Ok(())
148+
}
149+
150+
pub fn list(&mut self) -> Result<Vec<ClaudePermissionRequest>, diesel::result::Error> {
151+
let requests = crate::schema::claude_permission_requests::table
152+
.load::<ClaudePermissionRequest>(&mut self.db.conn)?;
153+
Ok(requests)
154+
}
155+
}
156+
82157
impl ClaudeSessionsHandle<'_> {
83158
pub fn insert(&mut self, session: ClaudeSession) -> Result<(), diesel::result::Error> {
84159
diesel::insert_into(claude_sessions)

crates/but-db/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ mod schema;
1414
mod workflows;
1515
pub use workflows::Workflow;
1616
mod claude;
17-
pub use claude::{ClaudeMessage, ClaudeSession};
17+
pub use claude::{ClaudeMessage, ClaudePermissionRequest, ClaudeSession};
1818
mod file_write_locks;
1919
pub use file_write_locks::FileWriteLock;
2020
mod workspace_rules;

0 commit comments

Comments
 (0)