Skip to content
This repository was archived by the owner on Aug 7, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ jobs:
run: |
cat > homebrew-tap/Formula/lambda-cli.rb << 'EOF'
class LambdaCli < Formula
desc "CLI and MCP server for Lambda Labs cloud GPU instances"
desc "Unofficial CLI and MCP server for Lambda cloud GPU instances"
homepage "https://github.com/Strand-AI/lambda-cli"
url "https://github.com/Strand-AI/lambda-cli/archive/refs/tags/v${{ needs.release.outputs.version }}.tar.gz"
sha256 "${{ steps.sha256.outputs.sha256 }}"
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "lambda_cli"
version = "0.4.0"
edition = "2021"
description = "CLI and MCP server for Lambda Labs cloud GPU instances"
description = "Unofficial CLI and MCP server for Lambda cloud GPU instances"
license = "MIT"
repository = "https://github.com/Strand-AI/lambda-cli"

Expand Down
55 changes: 50 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# Lambda CLI

> **⚠️ UNOFFICIAL PROJECT** — This is a community-built tool, not affiliated with or endorsed by Lambda.

[![CI](https://github.com/Strand-AI/lambda-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Strand-AI/lambda-cli/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![npm](https://img.shields.io/npm/v/@strand-ai/lambda-mcp)](https://www.npmjs.com/package/@strand-ai/lambda-mcp)
[![MCP](https://img.shields.io/badge/MCP-compatible-8A2BE2)](https://modelcontextprotocol.io)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=lambda-labs&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40strand-ai%2Flambda-mcp%22%5D%7D)
[![Install in Cursor](https://img.shields.io/badge/Cursor-Install_Server-000000?logo=cursor&logoColor=white)](cursor://anysphere.cursor-deeplink/mcp/install?name=lambda-labs&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40strand-ai%2Flambda-mcp%22%5D%7D)

A fast CLI and MCP server for managing [Lambda Labs](https://lambdalabs.com/) cloud GPU instances.
A fast CLI and MCP server for managing [Lambda](https://lambdalabs.com/) cloud GPU instances.

**Two ways to use it:**
- **CLI** (`lambda`) - Direct terminal commands for managing GPU instances
Expand All @@ -30,7 +32,7 @@ Download from [GitHub Releases](https://github.com/Strand-AI/lambda-cli/releases

## Authentication

Get your API key from the [Lambda Labs dashboard](https://cloud.lambdalabs.com/api-keys).
Get your API key from the [Lambda dashboard](https://cloud.lambdalabs.com/api-keys).

### Option 1: Environment Variable
```bash
Expand All @@ -44,6 +46,36 @@ export LAMBDA_API_KEY_COMMAND="op read op://Personal/Lambda/api-key"

The command is executed at startup and its output is used as the API key. This works with any secret manager.

## Notifications (Optional)

Get notified on Slack, Discord, or Telegram when your instance is ready and SSH-able.

### Configuration

Set one or more of these environment variables:

```bash
# Slack (incoming webhook)
export LAMBDA_NOTIFY_SLACK_WEBHOOK="https://hooks.slack.com/services/T00/B00/XXX"

# Discord (webhook URL)
export LAMBDA_NOTIFY_DISCORD_WEBHOOK="https://discord.com/api/webhooks/123/abc"

# Telegram (bot token + chat ID)
export LAMBDA_NOTIFY_TELEGRAM_BOT_TOKEN="123456:ABC-DEF..."
export LAMBDA_NOTIFY_TELEGRAM_CHAT_ID="123456789"
```

### Setup Guides

**Slack:** Create an [Incoming Webhook](https://api.slack.com/messaging/webhooks) in your workspace.

**Discord:** In channel settings → Integrations → Webhooks → New Webhook → Copy Webhook URL.

**Telegram:**
1. Message [@BotFather](https://t.me/botfather) → `/newbot` → copy the token
2. Message your bot, then visit `https://api.telegram.org/bot<TOKEN>/getUpdates` to find your chat ID

---

## CLI Usage
Expand Down Expand Up @@ -89,6 +121,8 @@ lambda find --gpu gpu_8x_h100 --ssh my-key --interval 30
| `-s, --ssh` | SSH key name (required) |
| `-n, --name` | Instance name |
| `-r, --region` | Region (auto-selects if omitted) |
| `-f, --filesystem` | Filesystem to attach (must be in same region) |
| `--no-notify` | Disable notifications even if env vars are set |

#### find
| Flag | Description |
Expand All @@ -97,12 +131,19 @@ lambda find --gpu gpu_8x_h100 --ssh my-key --interval 30
| `-s, --ssh` | SSH key name (required) |
| `--interval` | Poll interval in seconds (default: 10) |
| `-n, --name` | Instance name when launched |
| `-f, --filesystem` | Filesystem to attach when launched |
| `--no-notify` | Disable notifications even if env vars are set |

Notifications are **automatic** when env vars are configured. Use `--no-notify` to disable:
```bash
lambda start --gpu gpu_1x_a10 --ssh my-key --no-notify
```

---

## MCP Server

The `lambda-mcp` binary is an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that lets AI assistants manage your Lambda Labs infrastructure.
The `lambda-mcp` binary is an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that lets AI assistants manage your Lambda infrastructure.

### Quick Start with npx

Expand Down Expand Up @@ -135,11 +176,15 @@ npx @strand-ai/lambda-mcp --eager
| Tool | Description |
|------|-------------|
| `list_gpu_types` | List all available GPU instance types with pricing, specs, and current availability |
| `start_instance` | Launch a new GPU instance |
| `start_instance` | Launch a new GPU instance (auto-notifies if configured) |
| `stop_instance` | Terminate a running instance |
| `list_running_instances` | Show all running instances with status and connection details |
| `check_availability` | Check if a specific GPU type is available |

### Auto-Notifications

When notification environment variables are configured, the MCP server automatically sends notifications when instances become SSH-able. No additional flags needed—just set the `LAMBDA_NOTIFY_*` env vars and launch instances as usual.

### Claude Code Setup

```bash
Expand All @@ -157,7 +202,7 @@ Then restart Claude Code.

Once configured, you can ask Claude things like:

- "What GPUs are currently available on Lambda Labs?"
- "What GPUs are currently available on Lambda?"
- "Launch an H100 instance with my ssh key 'macbook'"
- "Show me my running instances"
- "Check if any A100s are available"
Expand Down
2 changes: 1 addition & 1 deletion npm/lambda-mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@strand-ai/lambda-mcp",
"version": "0.0.0",
"description": "MCP server for Lambda Labs cloud GPU instances",
"description": "Unofficial MCP server for Lambda cloud GPU instances",
"bin": "lib/index.js",
"main": "lib/index.js",
"type": "commonjs",
Expand Down
2 changes: 1 addition & 1 deletion scripts/update-homebrew.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ echo "Updating formula at $FORMULA_PATH..."

cat > "$FORMULA_PATH" << EOF
class LambdaCli < Formula
desc "CLI tool for Lambda Labs cloud GPU API"
desc "Unofficial CLI tool for Lambda cloud GPU API"
homepage "https://github.com/Strand-AI/lambda-cli"
url "https://github.com/Strand-AI/lambda-cli/archive/refs/tags/v${VERSION}.tar.gz"
sha256 "${SHA256}"
Expand Down
4 changes: 2 additions & 2 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ enum ApiKeySource {
Command(String),
}

/// Lambda Labs API client
/// Lambda API client
pub struct LambdaClient {
client: Client,
api_key_source: ApiKeySource,
Expand Down Expand Up @@ -244,7 +244,7 @@ impl LambdaClient {
.header(AUTHORIZATION, format!("Bearer {}", api_key))
.send()
.await
.context("Failed to connect to Lambda Labs API")?;
.context("Failed to connect to Lambda API")?;

if response.status().is_success() {
Ok(())
Expand Down
125 changes: 120 additions & 5 deletions src/bin/lambda-mcp.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
use anyhow::Result;
use lambda_cli::api::{Filesystem, Instance, InstanceTypeData, LambdaClient};
use lambda_cli::notify::{InstanceReadyMessage, Notifier, NotifyConfig};
use rmcp::handler::server::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo};
use rmcp::schemars::JsonSchema;
use rmcp::serde::Deserialize;
use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler, ServiceExt};
use std::sync::Arc;
use std::time::Duration;

/// Lambda Labs MCP Server
/// Lambda MCP Server
#[derive(Clone)]
struct LambdaService {
client: Arc<LambdaClient>,
notify_config: Option<NotifyConfig>,
#[allow(dead_code)]
tool_router: ToolRouter<Self>,
}
Expand All @@ -20,8 +23,21 @@ impl LambdaService {
fn new(lazy: bool) -> Result<Self> {
dotenv::dotenv().ok();
let client = LambdaClient::from_env_with_options(lazy)?;
let notify_config = NotifyConfig::from_env();

// Debug: log notification config status
if let Some(ref config) = notify_config {
eprintln!(
"[lambda-mcp] Notifications configured for: {}",
config.configured_channels().join(", ")
);
} else {
eprintln!("[lambda-mcp] No notification channels configured");
}

Ok(Self {
client: Arc::new(client),
notify_config,
tool_router: Self::tool_router(),
})
}
Expand Down Expand Up @@ -166,7 +182,9 @@ impl LambdaService {
)]))
}

#[tool(description = "Launch a new GPU instance. Returns instance ID and connection details. Optionally attach a filesystem (must be in the same region).")]
#[tool(
description = "Launch a new GPU instance. Returns instance ID and connection details. Optionally attach a filesystem (must be in the same region). If notification env vars are configured, will auto-notify when instance is SSH-able."
)]
async fn start_instance(
&self,
Parameters(params): Parameters<StartInstanceParams>,
Expand All @@ -189,9 +207,36 @@ impl LambdaService {
.map(|f| format!("\nFilesystem: {} (mounted at /lambda/nfs/{})", f, f))
.unwrap_or_default();

// Spawn background task to notify when instance is ready
let notify_status = if let Some(ref config) = self.notify_config {
let channels = config.configured_channels().join(", ");
let client = Arc::clone(&self.client);
let notifier = Notifier::new(config.clone());
let instance_id = result.instance_id.clone();
let instance_name = params.name.clone();
let gpu_type = params.gpu.clone();
let region = result.region.clone();

tokio::spawn(async move {
poll_and_notify(
client,
notifier,
instance_id,
instance_name,
gpu_type,
region,
)
.await;
});

format!("\n\nNotifications enabled for: {}. You will be notified when the instance is SSH-able.", channels)
} else {
String::new()
};

Ok(CallToolResult::success(vec![Content::text(format!(
"Instance launched successfully!\n\nInstance ID: {}\nRegion: {}{}\n\nNote: Instance may take a few minutes to become active. Use 'list_running_instances' to check status.",
result.instance_id, result.region, fs_info
"Instance launched successfully!\n\nInstance ID: {}\nRegion: {}{}\n\nNote: Instance may take a few minutes to become active. Use 'list_running_instances' to check status.{}",
result.instance_id, result.region, fs_info, notify_status
))]))
}

Expand Down Expand Up @@ -311,7 +356,7 @@ impl ServerHandler for LambdaService {
fn get_info(&self) -> ServerInfo {
ServerInfo {
instructions: Some(
"Lambda Labs GPU instance management. Use these tools to list available GPU types, \
"Lambda GPU instance management. Use these tools to list available GPU types, \
launch instances, check running instances, terminate instances, and manage \
persistent filesystems. Filesystems can be attached to instances at launch time \
for persistent storage. Requires LAMBDA_API_KEY environment variable to be set."
Expand All @@ -323,6 +368,76 @@ impl ServerHandler for LambdaService {
}
}

/// Background task to poll for instance readiness and send notifications
async fn poll_and_notify(
client: Arc<LambdaClient>,
notifier: Notifier,
instance_id: String,
instance_name: Option<String>,
gpu_type: String,
region: String,
) {
let max_wait = Duration::from_secs(600); // 10 minutes max
let poll_interval = Duration::from_secs(10);
let start = std::time::Instant::now();

loop {
if start.elapsed() > max_wait {
eprintln!(
"[notify] Timeout waiting for instance {} to become active",
instance_id
);
return;
}

tokio::time::sleep(poll_interval).await;

match client.get_instance(&instance_id).await {
Ok(instance) => {
let status = instance.status.as_deref().unwrap_or("unknown");

if status == "terminated" || status == "unhealthy" {
eprintln!(
"[notify] Instance {} entered {} state, stopping notifications",
instance_id, status
);
return;
}

// Notify when IP is available (don't wait for "active" status)
if let Some(ip) = instance.ip {
let msg = InstanceReadyMessage {
instance_id: instance_id.clone(),
instance_name,
ip,
gpu_type,
region,
};

let results = notifier.send_all(&msg).await;
for (channel, result) in results {
match result {
Ok(()) => eprintln!(
"[notify] {} notification sent for {}",
channel, instance_id
),
Err(e) => {
eprintln!("[notify] {} notification failed: {}", channel, e)
}
}
}
return;
}
// No IP yet, continue polling
}
Err(e) => {
eprintln!("[notify] Error checking instance {}: {}", instance_id, e);
// Continue polling on transient errors
}
}
}
}

#[tokio::main]
async fn main() -> Result<()> {
// Parse command line arguments
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub mod api;
pub mod notify;

pub use api::{Filesystem, Instance, InstanceTypeData, LambdaClient, LambdaError, LaunchResult};
pub use notify::{InstanceReadyMessage, Notifier, NotifyConfig};
Loading
Loading