Skip to content
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
6 changes: 4 additions & 2 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,19 +313,20 @@ Clic sur n'importe quel événement pour voir :

### 📖 Vue d'ensemble

Tracker sert un endpoint MCP sur `/mcp`, dans son propre serveur HTTP : les agents IA (Claude Code, Claude Desktop, Cursor, Kiro…) interrogent les events, le catalogue et les locks en langage naturel, sans rien installer. Les outils sont en lecture seule et soumis à l'authentification de Tracker (clés API, permissions, scope des équipes), comme l'API REST.
Tracker sert un endpoint MCP sur `/mcp`, dans son propre serveur HTTP : les agents IA (Claude Code, Claude Desktop, Cursor, Kiro…) interrogent les events, le catalogue et les locks en langage naturel, et peuvent enregistrer des events, sans rien installer. Les outils sont soumis à l'authentification de Tracker (clés API, permissions, scope des équipes), comme l'API REST.

> 📖 **Guide complet** : [docs/MCP_SERVER.md](./docs/MCP_SERVER.md)

### 🎯 Fonctionnalités

**8 outils disponibles :**
**9 outils disponibles :**

#### Events
- **`list_events`** : Liste les événements, du plus récent au plus ancien, avec filtres optionnels (type, service, statut)
- **`today_events`** : Événements qui commencent aujourd'hui
- **`search_events`** : Recherche par source, type, priorité, statut, service, environnement et plage de dates
- **`get_event`** : Un événement par son ID ou par l'ID de son message Slack
- **`create_event`** : Crée un événement (permission `event:write` ; un déploiement ou une opération qui démarre verrouille le service)

#### Catalog
- **`list_catalog`** : Liste les services du catalogue
Expand Down Expand Up @@ -365,6 +366,7 @@ Cursor, Kiro et les autres clients « Streamable HTTP » :
"Trouve les incidents P1 du service auth-service entre le 2026-01-01 et le 2026-01-15"
"Donne-moi les détails du service auth-service"
"Y a-t-il un lock sur le service payment-api ?"
"Enregistre le déploiement de payment-api 2.4.0 en production, ticket OPS-1234"
```

---
Expand Down
12 changes: 8 additions & 4 deletions docs/MCP_SERVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Tracker expose un serveur [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) directement dans son serveur HTTP, sur `/mcp`. Les assistants IA (Claude Code, Claude Desktop, Cursor, Kiro, etc.) peuvent ainsi interroger les events, le catalogue et les locks en langage naturel, **sans rien installer** : il suffit de pointer le client sur l'URL de Tracker.

Les outils sont en **lecture seule** et respectent l'authentification de Tracker : permissions, scope par service des équipes et accès anonyme s'appliquent exactement comme sur l'API REST.
Les outils lisent les events, le catalogue et les locks, et `create_event` permet d'enregistrer un event. Tous respectent l'authentification de Tracker : permissions, scope par service des équipes et accès anonyme s'appliquent exactement comme sur l'API REST.

## 🎯 Cas d'Usage

Expand Down Expand Up @@ -72,8 +72,9 @@ X-Api-Key: trk_...

Recommandations :

- Créez une **clé API dédiée** à l'usage MCP, rattachée à une équipe dont les permissions se limitent à `event:read`, `catalog:read` et `lock:read`. Voir [Authentification](./AUTHENTICATION.md#api-keys).
- Pour qu'une clé d'équipe isole vraiment les données de l'équipe, restreignez l'accès anonyme (`AUTH_ANONYMOUS_PERMISSIONS=`). Sinon un client sans clé voit tout ce que l'anonyme voit.
- Créez une **clé API dédiée** à l'usage MCP, rattachée à une équipe dont les permissions se limitent à `event:read`, `catalog:read` et `lock:read`, plus `event:write` si l'assistant doit créer des events. Voir [Authentification](./AUTHENTICATION.md#api-keys).
- Les events créés par une clé API ont pour propriétaire `apikey:<préfixe>` (le nom de l'utilisateur pour une session), sauf si `owner` est fourni.
- Pour qu'une clé d'équipe isole vraiment les données de l'équipe, restreignez l'accès anonyme (`AUTH_ANONYMOUS_PERMISSIONS=`). Sinon un client sans clé voit tout ce que l'anonyme voit. Tant que `AUTH_ANONYMOUS_PERMISSIONS` n'est pas défini, l'anonyme garde les permissions transitoires, écriture comprise : un client sans clé peut alors créer des events, comme sur l'API REST.
- Un refus (permission manquante, service hors scope) est renvoyé comme une erreur d'outil, lisible par l'assistant, par exemple `service "payment-api" is outside your scope`.

## 🔌 Configuration des Clients
Expand Down Expand Up @@ -132,6 +133,7 @@ Toutes les réponses sont du JSON au format de l'API REST (champs en camelCase,
| `today_events` | `per_page`, `page` | Events qui commencent aujourd'hui |
| `search_events` | `source`, `type`, `priority`, `status`, `service`, `environment`, `start_date`, `end_date`, `per_page`, `page` | Recherche multi-critères, au moins un filtre requis |
| `get_event` | `event_id` (requis) | Un event par son id ou par l'id de son message Slack |
| `create_event` | `title`, `type`, `service`, `environment`, `status` (requis) ; `priority`, `message`, `source`, `owner`, `start_date`, `end_date`, `impact`, `pull_request_link`, `ticket`, `related_id` | Crée un event (voir ci-dessous) |

Valeurs acceptées :

Expand All @@ -141,6 +143,8 @@ Valeurs acceptées :
- `environment` : `development`, `integration`, `TNR`, `UAT`, `recette`, `preproduction`, `production`, `mco`
- `start_date` / `end_date` : filtrent sur la date de début de l'event, au format `YYYY-MM-DD` ou RFC 3339 (`2026-01-15T10:30:00Z`)

`create_event` demande la permission `event:write` sur le service. Valeurs par défaut : `priority` `P3`, `source` `mcp`, `owner` l'appelant, `start_date` maintenant. Comme sur l'API, un `deployment` ou une `operation` créé avec le statut `start` ou `in_progress` pose un lock sur le service dans son environnement, et la création échoue si ce service y est déjà verrouillé. `related_id` relie l'event à un précédent, par exemple la fin d'un déploiement à son début.

### 📚 Catalog

| Outil | Paramètres | Description |
Expand Down Expand Up @@ -192,4 +196,4 @@ Pour ajouter un outil :

1. Déclarez-le avec `mcp.AddTool` dans `newMCPServer`, avec une structure d'entrée dont les tags `jsonschema` décrivent les paramètres.
2. Appelez le service avec `rpcContext(ctx, <Service>_<Rpc>_FullMethodName)` : la RPC applique elle-même permissions et scope.
3. Marquez-le `ReadOnlyHint` s'il ne modifie rien, et couvrez dans `mcp_test.go` le cas d'une clé d'équipe hors scope.
3. Marquez-le `ReadOnlyHint` s'il ne modifie rien (sinon `DestructiveHint: false` s'il ne fait qu'ajouter, comme `create_event`), et couvrez dans `mcp_test.go` le cas d'une clé d'équipe hors scope.
114 changes: 114 additions & 0 deletions server/mcp.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package server

import (
"cmp"
"context"
"errors"
"fmt"
"net/http"
"runtime/debug"
"sort"
"strings"
"time"

catalogv1 "github.com/bananaops/tracker/generated/proto/catalog/v1alpha1"
eventv1 "github.com/bananaops/tracker/generated/proto/event/v1alpha1"
Expand All @@ -18,6 +21,7 @@ import (
"google.golang.org/grpc/metadata"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"google.golang.org/protobuf/types/known/wrapperspb"
)

Expand Down Expand Up @@ -204,9 +208,119 @@ func newMCPServer(events *Event, catalogs *Catalog, locks *Lock) *mcp.Server {
return protoResult(r)
})

mcp.AddTool(srv, &mcp.Tool{
Name: "create_event",
Description: "Create a Tracker event (deployment, operation, drift, incident or RPA usage). " +
"A deployment or operation created with status start or in_progress also locks its service in its environment, " +
"and fails if that service is already locked there. Requires the event:write permission on the service.",
Annotations: &mcp.ToolAnnotations{DestructiveHint: new(false), OpenWorldHint: new(false)},
}, func(ctx context.Context, _ *mcp.CallToolRequest, in mcpCreateEventInput) (*mcp.CallToolResult, any, error) {
req, err := in.request(ctx, time.Now())
if err != nil {
return nil, nil, err
}
r, err := events.CreateEvent(rpcContext(ctx, eventv1.EventService_CreateEvent_FullMethodName), req)
if err != nil {
return nil, nil, err
}
return protoResult(r)
})

return srv
}

type mcpCreateEventInput struct {
Title string `json:"title" jsonschema:"short title, for example Deploy payment-api 2.3.0"`
Type string `json:"type" jsonschema:"event type: deployment, operation, drift, incident or rpa_usage"`
Service string `json:"service" jsonschema:"service name, as in the catalog"`
Environment string `json:"environment" jsonschema:"environment: development, integration, TNR, UAT, recette, preproduction, production or mco"`
Status string `json:"status" jsonschema:"status, for example start, in_progress, success, failure, open, close, done, planned"`
Priority string `json:"priority,omitempty" jsonschema:"priority: P1 to P5, default P3"`
Message string `json:"message,omitempty" jsonschema:"description of the event"`
Source string `json:"source,omitempty" jsonschema:"what reports the event, default mcp"`
Owner string `json:"owner,omitempty" jsonschema:"person or team in charge, default the caller (user name or apikey:<prefix>)"`
StartDate string `json:"start_date,omitempty" jsonschema:"start, YYYY-MM-DD or RFC 3339, default now"`
EndDate string `json:"end_date,omitempty" jsonschema:"end, YYYY-MM-DD or RFC 3339"`
Impact bool `json:"impact,omitempty" jsonschema:"true when the event impacts users"`
PullRequestLink string `json:"pull_request_link,omitempty" jsonschema:"URL of the pull request"`
Ticket string `json:"ticket,omitempty" jsonschema:"ticket reference, for example OPS-1234"`
RelatedID string `json:"related_id,omitempty" jsonschema:"id of the event this one follows, for example the start of a deployment"`
}

// request builds the CreateEvent request, with the defaults of the tool.
func (in mcpCreateEventInput) request(ctx context.Context, now time.Time) (*eventv1.CreateEventRequest, error) {
for field, value := range map[string]string{"title": in.Title, "service": in.Service} {
if strings.TrimSpace(value) == "" {
return nil, fmt.Errorf("%s is required", field)
}
}
attrs := &eventv1.EventAttributes{
Message: in.Message, Source: cmp.Or(in.Source, "mcp"), Service: in.Service, Impact: in.Impact,
Owner: cmp.Or(in.Owner, callerName(ctx)), RelatedId: in.RelatedID, Priority: eventv1.Priority_P3,
}
for _, e := range []struct {
value, field string
values map[string]int32
set func(int32)
required bool
}{
{in.Type, "type", eventv1.Type_value, func(v int32) { attrs.Type = eventv1.Type(v) }, true},
{in.Environment, "environment", eventv1.Environment_value, func(v int32) { attrs.Environment = eventv1.Environment(v) }, true},
{in.Status, "status", eventv1.Status_value, func(v int32) { attrs.Status = eventv1.Status(v) }, true},
{in.Priority, "priority", eventv1.Priority_value, func(v int32) { attrs.Priority = eventv1.Priority(v) }, false},
} {
if e.required && e.value == "" {
return nil, fmt.Errorf("%s is required", e.field)
}
if err := parseEnum(e.value, e.values, e.field, e.set); err != nil {
return nil, err
}
}
start, err := parseToolDate(in.StartDate, "start_date", now)
if err != nil {
return nil, err
}
attrs.StartDate = timestamppb.New(start)
if in.EndDate != "" {
end, err := parseToolDate(in.EndDate, "end_date", now)
if err != nil {
return nil, err
}
if end.Before(start) {
return nil, errors.New("end_date is before start_date")
}
attrs.EndDate = timestamppb.New(end)
}
return &eventv1.CreateEventRequest{
Title: in.Title,
Attributes: attrs,
Links: &eventv1.EventLinks{PullRequestLink: in.PullRequestLink, Ticket: in.Ticket},
}, nil
}

// parseToolDate reads a date as YYYY-MM-DD (midnight UTC) or RFC 3339; an
// empty value is now.
func parseToolDate(value, field string, now time.Time) (time.Time, error) {
if value == "" {
return now, nil
}
for _, layout := range []string{time.RFC3339, time.DateOnly} {
if t, err := time.Parse(layout, value); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("%s %q is neither YYYY-MM-DD nor RFC 3339", field, value)
}

// callerName names the caller in the event owner and its changelog: the
// username of a session, apikey:<prefix> for an API key, mcp otherwise.
func callerName(ctx context.Context) string {
if p, ok := auth.FromContext(ctx); ok && p.Username != "" {
return p.Username
}
return "mcp"
}

type mcpPage struct {
PerPage int `json:"per_page,omitempty" jsonschema:"number of items per page, default 10, at most 100"`
Page int `json:"page,omitempty" jsonschema:"page number starting at 1, default 1"`
Expand Down
68 changes: 67 additions & 1 deletion server/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,12 @@ func TestMCPListsReadOnlyTools(t *testing.T) {
for _, tool := range res.Tools {
names = append(names, tool.Name)
require.NotNil(t, tool.Annotations, tool.Name)
assert.True(t, tool.Annotations.ReadOnlyHint, tool.Name)
assert.Equal(t, tool.Name != "create_event", tool.Annotations.ReadOnlyHint, tool.Name)
}
assert.ElementsMatch(t, []string{
"list_events", "today_events", "search_events", "get_event",
"list_catalog", "get_catalog_service", "list_locks", "get_lock",
"create_event",
}, names)
}

Expand Down Expand Up @@ -240,3 +241,68 @@ func TestPaginate(t *testing.T) {
page, _ = paginate(items, mcpPage{PerPage: 1000})
assert.Len(t, page, 12, "per_page is capped, not rejected")
}

func TestMCPCreateEvent(t *testing.T) {
h, endpoint := newMCPE2E(t)
s := mcpSession(t, endpoint, apiKeyHeader(h.keyA))
create := func(s *mcp.ClientSession, args map[string]any) (map[string]any, string) {
out, res := callTool(t, s, "create_event", args)
if msg := toolError(res); msg != "" {
return nil, msg
}
return out["event"].(map[string]any), ""
}
base := func(extra map[string]any) map[string]any {
args := map[string]any{"title": "Deploy service-a 1.2.0", "type": "deployment", "service": "service-a",
"environment": "production", "status": "success"}
for k, v := range extra {
args[k] = v
}
return args
}

ev, msg := create(s, base(map[string]any{"ticket": "OPS-1", "start_date": "2026-01-15T10:00:00Z", "end_date": "2026-01-15T10:30:00Z"}))
require.Empty(t, msg)
attrs := ev["attributes"].(map[string]any)
assert.Equal(t, "service-a", attrs["service"])
assert.Equal(t, "deployment", attrs["type"])
assert.Equal(t, "P3", attrs["priority"], "default priority")
assert.Equal(t, "mcp", attrs["source"], "default source")
assert.True(t, strings.HasPrefix(attrs["owner"].(string), "apikey:"), "the owner names the key: %v", attrs["owner"])
assert.Equal(t, "2026-01-15T10:00:00Z", attrs["startDate"])
assert.Equal(t, "OPS-1", ev["links"].(map[string]any)["ticket"])

got, res := callTool(t, s, "get_event", map[string]any{"event_id": ev["metadata"].(map[string]any)["id"]})
require.Empty(t, toolError(res))
assert.Equal(t, "Deploy service-a 1.2.0", got["event"].(map[string]any)["title"])

// A session user owns the events it creates.
ev, msg = create(mcpSession(t, endpoint, http.Header{"Cookie": {h.alice.String()}}), base(nil))
require.Empty(t, msg)
assert.Equal(t, "alice", ev["attributes"].(map[string]any)["owner"])

// A deployment that starts locks its service, unless it is already locked.
_, msg = create(s, base(map[string]any{"status": "start"}))
assert.Contains(t, msg, "already locked", "service-a is locked in production by the fixture")
_, msg = create(s, base(map[string]any{"status": "start", "environment": "UAT"}))
require.Empty(t, msg)
locks, res := callTool(t, s, "list_locks", nil)
require.Empty(t, toolError(res))
assert.Len(t, listedServices(t, locks, "locks", fieldString("service")), 2)

_, msg = create(s, base(map[string]any{"service": "service-b"}))
assert.Contains(t, msg, `service "service-b" is outside your scope`)
_, msg = create(mcpSession(t, endpoint, nil), base(nil))
assert.NotEmpty(t, msg, "the fixture grants no anonymous permission")

for name, extra := range map[string]map[string]any{
"unknown type": {"type": "release"},
"missing title": {"title": " "},
"missing status": {"status": ""},
"bad date": {"start_date": "15/01/2026"},
"end before start": {"start_date": "2026-01-15", "end_date": "2026-01-14"},
} {
_, msg = create(s, base(extra))
assert.NotEmpty(t, msg, name)
}
}