scryer-mcp 0.2.1

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
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, Symbol};
use scryer_engine::IndexReport;

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

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

#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
pub struct ListProjectsParams {
    /// Optional search query to filter projects by slug or root path.
    pub query: Option<String>,
    /// Zero-based offset for paginating projects.
    pub offset: Option<usize>,
    /// Maximum number of projects to return.
    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 index and register as a project (absolute, or relative to the session's working directory), e.g. "/home/me/app". Must exist; a file, filesystem root or home directory is refused. Omit to refresh the active project.
    pub path: Option<String>,
    /// Optional project slug or ID override.
    pub project: Option<String>,
    /// If true, re-parses every file instead of only changed ones (default: false).
    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,
    pub file_count: usize,
    pub symbol_count: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub crate_count: Option<usize>,
    pub is_indexing: bool,
    /// 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 {
    /// Optional session ID filter. If omitted, aggregates across all sessions.
    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)
}

pub async fn handle_get_indexing_status(
    context: &ProjectContextResolver,
    db: &scryer_db::ScryerDb,
    params: GetIndexingStatusParams,
) -> anyhow::Result<IndexingStatusResult> {
    if let Some(target) = params.project.as_deref() {
        let trimmed = target.trim();
        if trimmed == "0"
            || trimmed.eq_ignore_ascii_case("dependencies")
            || trimmed.eq_ignore_ascii_case("cargo")
            || trimmed.eq_ignore_ascii_case("cargo-dependencies")
        {
            let mut guard = db.lock().await;
            let files = SourceFile::filter(SourceFile::fields().project_id().eq(0))
                .exec(&mut *guard)
                .await?;
            let symbols = Symbol::filter(Symbol::fields().project_id().eq(0))
                .exec(&mut *guard)
                .await?;
            let packages = DependencyPackage::all().exec(&mut *guard).await?;

            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,
                file_count: files.len(),
                symbol_count: symbols.len(),
                crate_count: Some(packages.len()),
                is_indexing: false,
                server: crate::build_info::ServerBuild::current(),
            });
        }
    }

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

    let mut guard = db.lock().await;
    let files = SourceFile::filter(SourceFile::fields().project_id().eq(project.id))
        .exec(&mut *guard)
        .await?;
    let symbols = Symbol::filter(Symbol::fields().project_id().eq(project.id))
        .exec(&mut *guard)
        .await?;

    Ok(IndexingStatusResult {
        project_id: project.id,
        slug: project.slug,
        root_path: project.root_path,
        file_count: files.len(),
        symbol_count: symbols.len(),
        crate_count: None,
        is_indexing: false,
        server: crate::build_info::ServerBuild::current(),
    })
}

pub async fn handle_get_token_savings_metrics(
    telemetry: &TokenSavingsMiddleware,
    params: GetTokenSavingsMetricsParams,
) -> anyhow::Result<TokenSavingsSummary> {
    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 Scryer's code index when a workspace is new, missing, or stale (e.g. results reference lines that moved). Scans incrementally by content hash by default; set full to force a complete scan. Pass `path` (a project directory) to register a new workspace; omit it to refresh the active project. An error if the path does not exist or is a filesystem root or the home directory.",
            mutating(true),
        ),
        make_tool::<GetIndexingStatusParams>(
            "get_indexing_status",
            "Use to check whether indexing is running or queued, or how many files are indexed, 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 Scryer tool usage or its token and latency impact. Aggregates invocations per tool, tokens saved versus reading files, query latencies and estimated cost reduction; 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
}