scryer-mcp 0.3.0

Model Context Protocol (MCP) server for Scryer code intelligence
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;

use rmcp::model::{Tool, ToolAnnotations};
use scryer_db::{DependencyPackage, Project, SourceFile};
use scryer_engine::IndexReport;

use crate::context::ProjectContextResolver;
use crate::telemetry::{TokenSavingsMiddleware, UsageSummary};

// --- Input & Output Types ---

#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct ListProjectsParams {
    /// Filter by slug or root path.
    pub query: Option<String>,
    /// Pagination offset.
    pub offset: Option<usize>,
    /// Maximum projects.
    pub limit: Option<usize>,
    /// Custom token budget for this response.
    pub max_tokens: Option<usize>,
    /// If true, bypasses token budget truncation.
    pub no_truncate: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ProjectSummary {
    pub id: u64,
    pub slug: String,
    pub root_path: String,
    /// Whether this is the project tools resolve to in this session when none is specified.
    pub is_active: bool,
    pub file_count: usize,
    pub created_at: String,
    pub updated_at: String,
}

#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct SwitchActiveProjectParams {
    /// Slug, root path, or ID of the project to activate.
    pub project: String,
    /// Custom token budget for this response.
    pub max_tokens: Option<usize>,
    /// If true, bypasses token budget truncation.
    pub no_truncate: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SwitchActiveProjectResult {
    pub success: bool,
    pub message: String,
    pub active_project_id: u64,
    pub active_project_slug: String,
    pub root_path: String,
}

#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct DeleteProjectParams {
    /// Exact slug, ID, or root path of the project to delete.
    pub project: String,
    /// Custom token budget for this response.
    pub max_tokens: Option<usize>,
    /// If true, bypasses token budget truncation.
    pub no_truncate: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DeleteProjectResult {
    pub success: bool,
    pub message: String,
    pub project_id: u64,
    pub slug: String,
    pub root_path: String,
    pub deleted_files: usize,
}

#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct IndexWorkspaceParams {
    /// Directory to register and index (absolute or relative to the working directory). Omit to refresh the active project.
    pub path: Option<String>,
    /// Optional project slug or ID override.
    pub project: Option<String>,
    /// Re-parse every file, not just changed ones.
    pub full: Option<bool>,
    /// Custom token budget for this response.
    pub max_tokens: Option<usize>,
    /// If true, bypasses token budget truncation.
    pub no_truncate: Option<bool>,
}

#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct GetIndexingStatusParams {
    /// Project slug, ID or root path; "0" or "dependencies" reports the shared Cargo dependency cache. Default: the active project.
    pub project: Option<String>,
    /// Custom token budget for this response.
    pub max_tokens: Option<usize>,
    /// If true, bypasses token budget truncation.
    pub no_truncate: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct IndexingStatusResult {
    pub project_id: u64,
    pub slug: String,
    pub root_path: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub crate_count: Option<usize>,
    /// Whether an index run is in flight: the first index (`building`) or a re-index of
    /// changed files (`stale`).
    pub is_indexing: bool,
    /// `ready`, `building 45% (n/N)` or `stale (k files pending)`.
    #[serde(default)]
    pub index_state: String,
    /// Files a running re-index has not updated yet (at most 20).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub pending_files: Vec<String>,
    /// Counts, languages, how references were resolved, and what the last run recorded.
    #[serde(flatten)]
    pub health: scryer_engine::IndexHealth,
    /// What is wrong with the index, one sentence each; absent when nothing is.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub problems: Vec<String>,
    /// Version and build of the server answering; `stale` means it runs replaced code.
    pub server: crate::build_info::ServerBuild,
}

#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct GetTokenSavingsMetricsParams {
    /// Filter to one session.
    pub session_id: Option<String>,
    /// Custom token budget for this response.
    pub max_tokens: Option<usize>,
    /// If true, bypasses token budget truncation.
    pub no_truncate: Option<bool>,
}

// --- Handler Functions ---

pub async fn handle_list_projects(
    context: &ProjectContextResolver,
    db: &scryer_db::ScryerDb,
    params: ListProjectsParams,
) -> anyhow::Result<Vec<ProjectSummary>> {
    let projects = context.registry().list_projects().await;
    let active_id = context
        .resolve_project(None, None)
        .await
        .ok()
        .map(|(p, _)| p.id);
    let mut summaries = Vec::new();

    let mut guard = db.lock().await;
    // The registry caches project rows from registration time; indexing bumps
    // `updated_at` in the database only.
    let updated_at: HashMap<u64, String> = Project::all()
        .exec(&mut *guard)
        .await?
        .into_iter()
        .map(|p| (p.id, p.updated_at))
        .collect();
    for p in projects {
        let files = SourceFile::filter(SourceFile::fields().project_id().eq(p.id))
            .exec(&mut *guard)
            .await?;
        summaries.push(ProjectSummary {
            id: p.id,
            slug: p.slug,
            root_path: p.root_path,
            is_active: Some(p.id) == active_id,
            file_count: files.len(),
            created_at: p.created_at,
            updated_at: updated_at.get(&p.id).cloned().unwrap_or(p.updated_at),
        });
    }

    if let Some(q) = &params.query {
        let q_lower = q.to_lowercase();
        summaries.retain(|s| {
            s.slug.to_lowercase().contains(&q_lower)
                || s.root_path.to_lowercase().contains(&q_lower)
        });
    }

    let offset = params.offset.unwrap_or(0);
    let paginated = if offset >= summaries.len() {
        Vec::new()
    } else {
        let remaining = &summaries[offset..];
        if let Some(limit) = params.limit {
            remaining.iter().take(limit).cloned().collect()
        } else {
            remaining.to_vec()
        }
    };

    Ok(paginated)
}

pub async fn handle_switch_active_project(
    context: &ProjectContextResolver,
    params: SwitchActiveProjectParams,
) -> anyhow::Result<SwitchActiveProjectResult> {
    let target = params.project.trim();
    anyhow::ensure!(
        !target.is_empty(),
        "`project` must be a project slug, ID or root path (see list_projects)"
    );
    let (project, _) = context.resolve_project(None, Some(target)).await?;
    context.set_active_project(project.id).await;

    Ok(SwitchActiveProjectResult {
        success: true,
        message: format!("Switched active project context to '{}'", project.slug),
        active_project_id: project.id,
        active_project_slug: project.slug,
        root_path: project.root_path,
    })
}

pub async fn handle_delete_project(
    context: &ProjectContextResolver,
    engine: &scryer_engine::EngineService,
    params: DeleteProjectParams,
) -> anyhow::Result<DeleteProjectResult> {
    let target = context.absolutize(Path::new(params.project.trim())).await;
    let registry = context.registry();
    // Match the raw argument first so slugs and IDs aren't mistaken for relative paths.
    let project = match registry.find(&params.project).await {
        Some(project) => project,
        None => registry
            .find(&target.to_string_lossy())
            .await
            .ok_or_else(|| {
                anyhow::anyhow!(
                    "No registered project matches '{}'. Use list_projects to see slugs, IDs, and root paths.",
                    params.project.trim()
                )
            })?,
    };

    let deleted_files = {
        let mut guard = engine.db().lock().await;
        SourceFile::filter(SourceFile::fields().project_id().eq(project.id))
            .exec(&mut *guard)
            .await?
            .len()
    };

    // Unregister first so indexing that starts after this point refuses the project,
    // then purge, which waits out any indexing already in flight.
    registry.remove(project.id).await?;
    engine.purge_project(project.id).await?;
    context.clear_active_project(project.id).await;

    Ok(DeleteProjectResult {
        success: true,
        message: format!(
            "Deleted project '{}' and its index data; files on disk were not touched",
            project.slug
        ),
        project_id: project.id,
        slug: project.slug,
        root_path: project.root_path,
        deleted_files,
    })
}

pub async fn handle_index_workspace(
    context: &ProjectContextResolver,
    engine: &scryer_engine::EngineService,
    params: IndexWorkspaceParams,
) -> anyhow::Result<IndexReport> {
    let target_path = match params.path.as_deref() {
        Some(path) => Some(context.absolutize(Path::new(path)).await),
        None => None,
    };
    let project = match target_path {
        Some(path) => {
            anyhow::ensure!(
                path.exists(),
                "Cannot index '{}': the path does not exist",
                path.display()
            );
            anyhow::ensure!(
                path.is_dir(),
                "Cannot index '{}': it is not a directory; pass the workspace root",
                path.display()
            );
            if scryer_db::is_too_broad_root(&path) {
                anyhow::bail!(
                    "Refusing to index '{}': it is a filesystem root or the home directory. Pass a project directory.",
                    path.display()
                );
            }
            context
                .registry()
                .register_or_update(&path, params.project.as_deref())
                .await?
        }
        None => {
            let (proj, _) = context
                .resolve_project(None, params.project.as_deref())
                .await?;
            proj
        }
    };

    let report = engine
        .index_project(project.id, Path::new(&project.root_path))
        .await?;

    Ok(report)
}

/// `ready`, `building 45% (n/N)` or `stale (k files pending)`, and the pending files.
pub fn describe_index_state(state: &scryer_engine::IndexState) -> (String, Vec<String>) {
    use scryer_engine::IndexState;
    match state {
        IndexState::Ready => ("ready".to_string(), Vec::new()),
        IndexState::Building { done, total, .. } => (
            format!(
                "building {}% ({done}/{total})",
                state.percent().unwrap_or_default()
            ),
            Vec::new(),
        ),
        IndexState::Stale { pending } => (
            format!("stale ({} files pending)", pending.len()),
            pending.iter().take(20).cloned().collect(),
        ),
    }
}

pub async fn handle_get_indexing_status(
    context: &ProjectContextResolver,
    engine: &scryer_engine::EngineService,
    db: &scryer_db::ScryerDb,
    params: GetIndexingStatusParams,
) -> anyhow::Result<IndexingStatusResult> {
    if let Some(target) = params.project.as_deref() {
        let trimmed = target.trim();
        // A registered project's own name wins over the dependency-cache aliases, so a project
        // called `cargo` can still be asked about.
        let is_registered = context.registry().find(trimmed).await.is_some();
        if !is_registered
            && (trimmed == "0"
                || trimmed.eq_ignore_ascii_case("dependencies")
                || trimmed.eq_ignore_ascii_case("cargo")
                || trimmed.eq_ignore_ascii_case("cargo-dependencies"))
        {
            let health = scryer_engine::index_health(db, 0).await?;
            let packages = {
                let mut guard = db.lock().await;
                DependencyPackage::all().exec(&mut *guard).await?.len()
            };

            let cargo_home = std::env::var("CARGO_HOME")
                .or_else(|_| std::env::var("HOME").map(|h| format!("{h}/.cargo")))
                .unwrap_or_else(|_| "~/.cargo".to_string());

            return Ok(IndexingStatusResult {
                project_id: 0,
                slug: "cargo-dependencies".to_string(),
                root_path: cargo_home,
                crate_count: Some(packages),
                is_indexing: false,
                index_state: "ready".to_string(),
                pending_files: Vec::new(),
                problems: Vec::new(),
                health,
                server: crate::build_info::ServerBuild::current(),
            });
        }
    }

    let (project, _) = context
        .resolve_project(None, params.project.as_deref())
        .await?;

    let health = scryer_engine::index_health(db, project.id).await?;
    let state = engine.index_state(project.id);
    let (index_state, pending_files) = describe_index_state(&state);
    Ok(IndexingStatusResult {
        project_id: project.id,
        slug: project.slug,
        root_path: project.root_path,
        crate_count: None,
        is_indexing: state.is_indexing(),
        index_state,
        pending_files,
        problems: health.problems(),
        health,
        server: crate::build_info::ServerBuild::current(),
    })
}

pub async fn handle_get_token_savings_metrics(
    telemetry: &TokenSavingsMiddleware,
    params: GetTokenSavingsMetricsParams,
) -> anyhow::Result<UsageSummary> {
    telemetry.get_metrics(params.session_id.as_deref()).await
}

// --- Tool Definitions ---

pub fn tool_definitions() -> Vec<Tool> {
    vec![
        make_tool::<ListProjectsParams>(
            "list_projects",
            "Use to see registered projects or confirm which one is active before project-scoped queries. Returns each project's slug, root, file count and timestamps.",
            read_only(),
        ),
        make_tool::<SwitchActiveProjectParams>(
            "switch_active_project",
            "Use when later calls without an explicit project should resolve against a different registered project. Changes this session's active project; does not touch the index. An error if no registered project matches.",
            mutating(true),
        ),
        make_tool::<DeleteProjectParams>(
            "delete_project",
            "Use only when project removal is explicitly intended. Unregisters the project and deletes its index data and ADR database records; source files and ADR Markdown on disk remain untouched.",
            destructive(),
        ),
        make_tool::<IndexWorkspaceParams>(
            "index_workspace",
            "Use to create or refresh the code index when a workspace is new or results look stale. Incremental by content hash; full forces a complete scan. Pass path to register a new workspace, omit it to refresh the active project. Errors on a missing path, a filesystem root or the home directory.",
            mutating(true),
        ),
        make_tool::<GetIndexingStatusParams>(
            "get_indexing_status",
            "Use to check whether indexing is running or queued, how many files are indexed, or what could not be indexed (`problems`), before trusting query results. Pass project: \"0\" or \"dependencies\" to inspect the global Cargo dependency cache.",
            read_only(),
        ),
        make_tool::<GetTokenSavingsMetricsParams>(
            "get_token_savings_metrics",
            "Use when reviewing how Scryer has been used. Reports measured usage only, not savings: calls, response tokens, truncations, follow-up rate (another Scryer call right after) and latency, per tool and overall; optionally filter by session.",
            read_only(),
        ),
    ]
}

/// Annotations for a tool that only reads the index or the filesystem.
pub(crate) fn read_only() -> ToolAnnotations {
    ToolAnnotations::new()
        .read_only(true)
        .destructive(false)
        .idempotent(true)
        .open_world(false)
}

/// Annotations for a tool that changes Scryer state without deleting anything.
pub(crate) fn mutating(idempotent: bool) -> ToolAnnotations {
    ToolAnnotations::new()
        .read_only(false)
        .destructive(false)
        .idempotent(idempotent)
        .open_world(false)
}

/// Annotations for a tool that permanently deletes data.
pub(crate) fn destructive() -> ToolAnnotations {
    ToolAnnotations::new()
        .read_only(false)
        .destructive(true)
        .idempotent(true)
        .open_world(false)
}

pub(crate) fn make_tool<T: JsonSchema>(
    name: &'static str,
    description: &'static str,
    annotations: ToolAnnotations,
) -> Tool {
    let schema = schemars::schema_for!(T);
    let val = serde_json::to_value(schema).unwrap_or(Value::Object(Default::default()));
    let mut obj = match val {
        Value::Object(map) => map,
        _ => Default::default(),
    };
    super::schema::simplify_schema(name, &mut obj);

    let mut tool = Tool::default();
    tool.name = name.into();
    tool.title = Some(name.to_string());
    tool.description = Some(description.into());
    tool.input_schema = Arc::new(obj);
    tool.annotations = Some(annotations);
    tool
}