| Platform |
Schema format |
Registration |
Invocation |
| GitHub Copilot Extension |
Markdown description + REST endpoint |
GitHub App manifest |
@your-extension in Copilot Chat |
| Claude Tools (API) |
JSON Schema |
tools:[] parameter on each API call |
Model invokes via tool_use content block |
| MCP Server |
JSON Schema over MCP protocol |
claude_desktop_config.json or MCP client config |
Any MCP-compatible client |
Spring AI @Tool |
Auto-derived from method signature |
ChatClient.tools() call |
Model invokes through Spring AI framework |
The Skill We're Building
Deployment Status Checker — given a service name and optional environment, returns the current deployment status, last deploy time, version, and any active incidents.
This is representative because:
- It requires an external API call (your deployment platform — Kubernetes, Argo CD, whatever you use)
- It takes structured parameters
- It returns structured data the AI formats for humans
- Every developer wants it in their IDE assistant
We'll implement it three ways and compare what changes.
GitHub Copilot Extensions let you create a custom @your-extension participant in Copilot Chat. Users invoke it with @deploy-status what's the state of the payment service in prod? and your extension receives the conversation, calls your backend, and streams a response back.
Architecture
flowchart TD
A[Developer types\n@deploy-status in\nCopilot Chat] --> B[GitHub routes message\nto your extension's\nwebhook URL]
B --> C[Your Extension Server\nNode.js / Java / Python]
C -->|Verify GitHub\nHMAC signature| D{Valid?}
D -->|No| E[401 Unauthorized]
D -->|Yes| F[Parse conversation\nfrom payload]
F --> G[Call your\ndeployment API]
G --> H[Optionally call\nCopilot LLM API\nto format response]
H -->|SSE stream| I[Response appears\nin Copilot Chat]
style C fill:#1e3a5f,color:#7dd3fc
style I fill:#065f46,color:#6ee7b7
Setting Up the GitHub App
Create a GitHub App at github.com/settings/apps/new with:
- Webhook URL: your extension server's
/api/github/copilot endpoint
- Permissions: Copilot Chat → Read (under Account permissions)
- Copilot: check "Copilot Extension" in the app settings
- Callback URL: for OAuth if your skill needs to act on behalf of the user
{
"name": "deploy-status",
"description": "Check deployment status, versions, and incidents for any service",
"instructions": "Use this extension when the user asks about deployment status, service versions, recent deployments, or production incidents. It can check any service across dev, staging, and prod environments.",
"skill_sets": [
{
"name": "deployment",
"skills": [
{
"id": "get-deploy-status",
"description": "Returns the current deployment status and version for a named service",
"parameters": {
"type": "object",
"properties": {
"service": { "type": "string", "description": "The service name, e.g. payment-service" },
"environment": { "type": "string", "enum": ["dev", "staging", "prod"], "default": "prod" }
},
"required": ["service"]
}
}
]
}
]
}
The Extension Server
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
import Anthropic from "@anthropic-ai/sdk";
const app = express();
app.use(express.json());
const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
const COPILOT_TOKEN = process.env.COPILOT_TOKEN;
function verifySignature(req) {
const sig = req.headers["x-github-token"] ?? "";
const payload = JSON.stringify(req.body);
const digest = createHmac("sha256", WEBHOOK_SECRET).update(payload).digest("hex");
return timingSafeEqual(Buffer.from(`sha256=${digest}`), Buffer.from(sig));
}
app.post("/api/github/copilot", async (req, res) => {
if (!verifySignature(req)) return res.status(401).send("Unauthorized");
const { messages } = req.body;
const lastMessage = messages.at(-1)?.content ?? "";
const serviceMatch = lastMessage.match(/\b([a-z][a-z0-9-]+(?:-service|-api|-worker|-gateway))\b/i);
const service = serviceMatch?.[1] ?? null;
const envMatch = lastMessage.match(/\b(dev|staging|prod|production)\b/i);
const environment = envMatch?.[1]?.replace("production", "prod") ?? "prod";
const status = service
? await fetchDeployStatus(service, environment)
: null;
const copilotClient = new Anthropic({
baseURL: "https://api.githubcopilot.com",
apiKey: req.headers["x-github-token"],
});
res.setHeader("Content-Type", "text/event-stream");
const stream = await copilotClient.messages.stream({
model: "gpt-4o",
max_tokens: 1024,
messages: [
{
role: "system",
content: "You are a helpful deployment assistant. Format deployment status clearly and concisely. Highlight any issues or incidents prominently."
},
...messages,
...(status ? [{
role: "system",
content: `Deployment data for ${service} in ${environment}: ${JSON.stringify(status)}`
}] : [])
]
});
for await (const chunk of stream) {
if (chunk.type === "content_block_delta") {
res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: chunk.delta.text } }] })}\n\n`);
}
}
res.write("data: [DONE]\n\n");
res.end();
});
async function fetchDeployStatus(service, env) {
const res = await fetch(`${process.env.DEPLOY_API_URL}/services/${service}/status?env=${env}`, {
headers: { Authorization: `Bearer ${process.env.DEPLOY_API_TOKEN}` }
});
if (!res.ok) return { error: `Service ${service} not found in ${env}` };
return res.json();
}
app.listen(3000, () => console.log("Copilot extension running on :3000"));
[!NOTE]
GitHub Copilot Extensions receive a short-lived x-github-token in each request. Use this token to call GitHub's Copilot LLM API — you don't need your own Anthropic or OpenAI key for the language generation part. You do need your own key if you want to call a different model for specific tasks inside your extension.
Claude's tool use lets you define callable functions in the API request. The model reads your schema, decides when to call your tool, and sends a structured tool_use request. Your code executes the tool and returns the result. Claude then incorporates the result into its response.
Spring AI Implementation
Spring AI makes Claude tool registration almost declarative with @Tool:
@Component
public class DeploymentSkill {
private final DeploymentApiClient deploymentApi;
@Tool(description = """
Returns the current deployment status, version, last deploy time, and active incidents
for a named service. Use when the user asks about service health, deployment state,
running version, or recent deployments.
""")
public DeploymentStatus getDeploymentStatus(
@ToolParam(description = "The service name, e.g. payment-service or user-api") String service,
@ToolParam(description = "Environment: dev, staging, or prod. Default is prod.") String environment) {
return deploymentApi.getStatus(service, environment.isBlank() ? "prod" : environment);
}
@Tool(description = """
Lists the last N deployments for a service, including who triggered them and whether
they succeeded. Use when the user asks about deployment history or recent changes.
""")
public List<DeploymentEvent> getDeploymentHistory(
@ToolParam(description = "The service name") String service,
@ToolParam(description = "Number of recent deployments to return, max 20") int limit) {
return deploymentApi.getHistory(service, Math.min(limit, 20));
}
}
public record DeploymentStatus(
String service,
String environment,
String version,
String status, // "healthy" | "degraded" | "down" | "deploying"
Instant lastDeployedAt,
String deployedBy,
int activeIncidents,
String incidentSummary // null if no active incidents
) {}
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatClient chatClient;
private final DeploymentSkill deploymentSkill;
@PostMapping
public Flux<String> chat(@RequestBody ChatRequest req) {
return chatClient
.prompt()
.system("""
You are an AI assistant for engineering teams. You have access to deployment
status tools. Use them proactively when the user asks about services,
deployments, incidents, or production health.
""")
.user(req.message())
.tools(deploymentSkill)
.stream()
.content();
}
}
Spring AI reads your @Tool methods at startup, generates the JSON Schema from the parameter types and @ToolParam descriptions, and includes them in every Claude API call automatically. When Claude decides to use a tool, Spring AI intercepts the tool_use block, invokes your method with the deserialized arguments, and feeds the result back to Claude.
What the API Request Actually Looks Like
Understanding the wire format helps debug issues:
{
"model": "claude-opus-5",
"messages": [{ "role": "user", "content": "What version is payment-service running in prod?" }],
"tools": [
{
"name": "getDeploymentStatus",
"description": "Returns the current deployment status, version, last deploy time...",
"input_schema": {
"type": "object",
"properties": {
"service": { "type": "string", "description": "The service name, e.g. payment-service" },
"environment": { "type": "string", "description": "Environment: dev, staging, or prod..." }
},
"required": ["service"]
}
}
]
}
{
"role": "assistant",
"content": [
{ "type": "text", "text": "Let me check the deployment status for payment-service in prod." },
{
"type": "tool_use",
"id": "toolu_01XyzAbc",
"name": "getDeploymentStatus",
"input": { "service": "payment-service", "environment": "prod" }
}
]
}
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01XyzAbc",
"content": "{\"service\":\"payment-service\",\"environment\":\"prod\",\"version\":\"2.14.1\",...}"
}
]
}
Claude then reads the tool result and writes a natural language response.
The Model Context Protocol (MCP) is an open standard that separates tool implementation from tool consumption. You build an MCP server once, and any MCP-compatible client — Claude Desktop, Claude Code, Cursor, Zed, your own agent — can connect to it without any changes.
flowchart TD
subgraph "MCP Clients"
A[Claude Desktop]
B[Claude Code CLI]
C[Cursor IDE]
D[Your Custom Agent]
end
subgraph "Your MCP Server"
E[Tool: get_deploy_status]
F[Tool: list_deployments]
G[Tool: trigger_rollback]
H[Resource: /incidents/active]
end
subgraph "Your Backend"
I[Argo CD API]
J[PagerDuty API]
K[GitHub Actions API]
end
A <-->|MCP protocol\nover stdio / SSE| E
B <-->|MCP protocol| E
C <-->|MCP protocol| F
D <-->|MCP protocol| G
E --> I
F --> I
G --> K
H --> J
style E fill:#1e3a5f,color:#7dd3fc
style F fill:#1e3a5f,color:#7dd3fc
style G fill:#1e3a5f,color:#7dd3fc
Spring AI MCP Server
@SpringBootApplication
@EnableMcpServer
public class DeploymentMcpServer {
public static void main(String[] args) {
SpringApplication.run(DeploymentMcpServer.class, args);
}
}
@McpServer
@Component
public class DeploymentTools {
private final DeploymentApiClient api;
@McpTool(
name = "get_deploy_status",
description = "Returns current deployment status, version, and incident summary for a service"
)
public DeploymentStatus getStatus(
@McpParam("service") String service,
@McpParam("environment") @Nullable String environment) {
return api.getStatus(service, environment != null ? environment : "prod");
}
@McpTool(
name = "list_recent_deployments",
description = "Lists the last N deployments with timestamps, deployers, and outcomes"
)
public List<DeploymentEvent> listDeployments(
@McpParam("service") String service,
@McpParam("limit") @Nullable Integer limit) {
return api.getHistory(service, limit != null ? Math.min(limit, 20) : 5);
}
@McpTool(
name = "trigger_rollback",
description = "Triggers a rollback to the previous stable version for a service in a given environment. REQUIRES explicit user confirmation in the conversation before calling."
)
public RollbackResult triggerRollback(
@McpParam("service") String service,
@McpParam("environment") String environment) {
return api.rollback(service, environment);
}
@McpResource(uri = "deploy://incidents/active", description = "Active production incidents")
public String activeIncidents() {
return api.getActiveIncidents().stream()
.map(i -> "- [%s] %s (since %s)".formatted(i.severity(), i.title(), i.startedAt()))
.collect(Collectors.joining("\n"));
}
}
spring:
ai:
mcp:
server:
transport: stdio
name: deploy-status-mcp
version: 1.0.0
Registering with Claude Code
{
"mcpServers": {
"deploy-status": {
"command": "java",
"args": ["-jar", "/opt/tools/deploy-status-mcp.jar"],
"env": {
"DEPLOY_API_URL": "https://deploy.yourcompany.internal",
"DEPLOY_API_TOKEN": "your-token-here"
}
}
}
}
After adding this, Claude Code picks up your tools automatically. Users can run /mcp to list available tools and see get_deploy_status, list_recent_deployments, and trigger_rollback from your server.
Claude Code has its own skill system for defining reusable slash commands — procedures Claude follows when you type /deploy-check in a session.
<!-- .claude/skills/deploy-check.md -->
---
name: deploy-check
description: Check deployment status for a service. Usage: /deploy-check [service] [env]
---
Check the deployment status for the given service and environment.
## Steps
1. If service name is not provided, ask the user: "Which service? (e.g. payment-service, user-api)"
2. If environment is not provided, default to `prod`
3. Run: `curl -s -H "Authorization: Bearer $DEPLOY_API_TOKEN" \
"$DEPLOY_API_URL/services/{service}/status?env={env}" | jq .`
4. Format the output:
- Show service name, environment, version, and status on the first line
- If status is not "healthy", highlight it prominently with what's wrong
- Show the last deploy time and who triggered it
- If there are active incidents, list them with severity and duration
5. If the API call fails (non-200 response), say "Could not reach the deployment API —
check that DEPLOY_API_TOKEN and DEPLOY_API_URL are set in your environment"
## Output format
[service] @ [env] — v[version] — [status]
Last deployed: [time] by [deployer]
[If incidents:] ⚠️ [N] active incident(s): [summary]
Skills defined in .claude/skills/ are automatically available as /skill-name in every Claude Code session in that project. The CLAUDE.md in your project can also reference them:
<!-- CLAUDE.md -->
## Available slash commands
- `/deploy-check [service] [env]` — Check deployment status and active incidents
- `/review` — Run AI code review on current diff
- `/cost-check` — Review token usage and estimated costs for this sprint
Writing Skill Descriptions That Work
The single highest-leverage thing you can do to make a skill perform well is write a great description. The model reads your description to decide when to invoke the skill. Bad descriptions lead to missed invocations or wrong invocations.
Bad description:
"Gets deployment info"
Good description:
"Returns the current deployment status, running version, last deploy timestamp, and active PagerDuty incidents for a named service in a specified environment (dev/staging/prod). Use when the user asks about: is service X running? what version is deployed? when was the last deployment? are there any active incidents? what's wrong with service Y?"
The difference:
- Specificity — lists exactly what the tool returns
- Trigger phrases — "Use when the user asks about..." gives the model explicit routing signals
- Examples — concrete sample questions help the model recognize when to invoke
Apply the same principle to parameter descriptions:
@ToolParam("service name") String service
@ToolParam("The exact service name as it appears in the deployment system, e.g. 'payment-service', 'user-api', 'notification-worker'. If the user gives a partial name, use your best judgment.") String service
flowchart TD
A{Where will\nusers invoke\nthe skill?}
A -->|In GitHub Copilot Chat\nin their IDE| B[GitHub Copilot Extension]
A -->|In Claude.ai or\nClaude Code| C{Persistent connection\nor per-call?}
A -->|In your own app\nor API| D{Language / framework?}
A -->|Any MCP client\nCursor, Zed, etc.| E[MCP Server]
C -->|One-off, embedded\nin app logic| F[Claude Tool Use\nvia API directly]
C -->|Persistent, IDE-like\nmulti-session| E
D -->|Java / Spring Boot| G[Spring AI @Tool\nor MCP Server]
D -->|Any other| H[Claude API tool_use\nin your language]
style B fill:#1e3a5f,color:#7dd3fc
style E fill:#065f46,color:#6ee7b7
style G fill:#374151,color:#d1d5db
| Use case |
Best platform |
IDE assistant for developers (@your-team) |
GitHub Copilot Extension |
| App feature powered by Claude (summarize, analyze, assist) |
Claude Tools via API (tool_use) |
| Developer tool available in Claude Code / Cursor / Zed |
MCP Server |
| Team automation inside Claude Code sessions |
Claude Code Skill (SKILL.md) |
| Enterprise integration across multiple AI clients |
MCP Server (platform-agnostic) |
| Prototype / internal hackathon |
Claude Tools via API (fastest to build) |
Regardless of which platform you choose, four implementation rules apply everywhere:
1. Return structured data, let the AI format it for humans.
Don't return "Payment service is running version 2.14.1 and was last deployed at 3:42 PM." Return { version: "2.14.1", lastDeployedAt: "2026-09-02T15:42:00Z", status: "healthy" }. The AI formats prose better than you do. Structured data also survives prompt changes without touching your implementation.
2. Fail loudly with context.
throw new RuntimeException("Not found");
return new SkillError("SERVICE_NOT_FOUND",
"No service named '" + service + "' found in " + environment + ". " +
"Available services: payment-service, user-api, notification-worker");
3. Add safety rails on destructive operations.
For tools like trigger_rollback, add a requirement in the description: "ONLY invoke after the user has explicitly confirmed they want to roll back, e.g. 'yes, roll it back' or 'proceed with rollback'." The model respects this instruction.
4. Keep tool count low.
More tools = more confusion about which one to invoke. Target 3–5 well-named tools rather than 12 narrowly-scoped ones. If you have more, group them into domain-specific servers/extensions.
Key Takeaways
GitHub Copilot Extensions are best for developer tools that live inside the IDE alongside code — your team can invoke them with @your-extension without leaving the editor.
Claude Tools (API) are best for embedding AI capabilities inside your own applications — an AI assistant inside your product, a chatbot, a document analyzer. You control the full stack.
MCP Servers are the most portable option — build once, run in any MCP-compatible client. This is the right choice for tools you want available everywhere your team uses AI.
Claude Code Skills are configuration, not code — great for standardizing team workflows and creating shared slash commands without a deployment.
The description is the routing logic. A well-written description is worth more than any amount of prompt engineering around the tool invocation. Invest here first.
Return structured data. Let the AI format results for humans. Your job is to return correct, complete data in a machine-readable format. The AI's job is to turn that into a clear, contextualized response.
The skills you build today will run in assistants your team doesn't know about yet. Invest 30 extra minutes on the schema and description — it compounds across every client that ever connects to your skill.