a365 is a thin MCP (Model Context Protocol) client that talks JSON-RPC over HTTP+SSE to Microsoft's agent365 gateway. It doesn't use the Graph REST API, Graph SDK, or any MCP SDK — the entire MCP transport layer is hand-written Go (~300 LOC).
User
|
a365 CLI (kong)
|
├── commands/ Parse args, apply safety guards (dry-run, confirm)
|
├── output/ Extract MCP response → table / JSON / TSV
|
├── mcp/client JSON-RPC over HTTP+SSE, retry, session cache
|
└── auth/ InteractiveBrowserCredential + PKCE + auth record
|
agent365.svc.cloud.microsoft
/agents/servers/{mcp_server}/
Every command follows the same path:
- Parse — kong parses CLI args into a typed Go struct
- Safety —
--dry-runconnects to the server, fetches tool schemas viaListToolsCached(), validates args against the JSON Schema, and prints the result without executing the tool; destructive ops prompt viactx.Confirm() - Auth —
EnsureAuth()loads cached credentials or triggers browser login - Session —
Initialize()checks~/.a365/sessions.jsonfor a cached session; if valid, skips the MCP handshake - Request —
CallTool()sends a JSON-RPCtools/callPOST withAuthorization: BearerandMcp-Session-Idheaders - Retry — on 502/503/429/504, retries up to 2x with exponential backoff (1s, 2s); respects
Retry-Afterheader - Response — parses SSE stream (
data:lines) or plain JSON; extracts the first JSON-RPC message - Extract —
ExtractContent()unwraps 3 response patterns (clean JSON, embedded JSON after status text, rawResponse-wrapped) - Render —
PrintList/PrintItem/PrintMutationdispatches to table (tabwriter), JSON, or TSV based on--output
- HTTP POST to
https://agent365.svc.cloud.microsoft/agents/servers/{server}/ - Content-Type:
application/json - Accept:
application/json, text/event-stream - Response: either
application/json(plain) ortext/event-stream(SSE)
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"..."}]}}
The parser handles both data: (with space) and data: (without space) per the SSE spec.
Each service gets its own MCP session:
- First request sends
initialize→ server returnsMcp-Session-Idheader - Subsequent requests include the session ID
- Sessions cached in
~/.a365/sessions.jsonwith 30-minute TTL (includes tool schemas for--dry-runvalidation) - On session errors (401/403, "invalid session"), cache is cleared and session re-established automatically
The agent365 servers return data in 3 formats:
| Pattern | Example | How it looks |
|---|---|---|
| Clean JSON | Teams | Content[0].Text = {"teams":[...]} |
| Embedded | Calendar | Content[0].Text = "Success.\n{\"value\":[...]}" |
| Wrapped | Content[0].Text = {"rawResponse":"{\"value\":[...]}","message":"..."} |
ExtractContent() handles all three transparently.
Browser ──PKCE──► Entra ID (login.microsoftonline.com)
|
Access Token (JWT)
|
├── Scope: ea9ffc3e-.../.default (all agent365 scopes)
├── Cached in ~/.a365/auth-record.json for silent refresh
└── Auth record in ~/.a365/auth-record.json for silent refresh
- Flow: Interactive browser + PKCE (passes org Conditional Access on managed devices)
- Token cache:
~/.a365/auth-record.jsonenables silent token refresh across CLI invocations - Auth record:
~/.a365/auth-record.jsonenables silent token refresh across CLI invocations - Scope:
ea9ffc3e-8a23-4a7d-836d-234d7c7565c1/.default— requests all granted agent365 scopes at once
MCP JSONRPCResponse
→ ExtractContent() (extract.go) → map[string]any
→ ToRows() (extract.go) → []map[string]any
→ PrintList() (formatter.go) → format dispatch
├── FormatHuman → RenderTable() (render.go) → text/tabwriter
├── FormatJSON → writeJSON() (formatter.go) → json.Encoder
└── FormatPlain → RenderTSV() (render.go) → raw tabs
Each entity type has column definitions in columns.go that extract and format fields:
- Width — max chars for table display (0 = unlimited)
- Extract — function that pulls a display value from
map[string]any - HTML stripping — Teams messages have HTML content;
stripHTML()handles emoji, attachment, codeblock, img tags
The server map in config.go provides the default mapping of friendly names to MCP server names. The api discover command can also query the live catalog:
GET https://agent365.svc.cloud.microsoft/agents/discoverToolServers
This returns all available servers with their URLs, scopes, and audiences — useful for finding new servers Microsoft has added.
| Decision | Why |
|---|---|
| Kong over Cobra | Struct-tag CLI definition, less boilerplate; 173 commands as struct fields |
| Hand-written MCP client | ~300 LOC; the protocol is simple enough that an SDK adds complexity without value |
map[string]any over typed structs |
MCP responses vary across 24 servers; untyped maps are forward-compatible |
text/tabwriter for tables |
Standard library, zero dependencies |
| Retry at HTTP layer | Catches transient 502/503/429 from the gateway without command-level changes |
| Session cache as JSON file | Simple, debuggable, no external dependencies |
| Auth record for tokens | Simple file-based, portable, no OS-specific prompts |
| Directory | Purpose |
|---|---|
internal/mcp/ |
MCP JSON-RPC client, SSE parser, session cache, types, schema validation |
internal/auth/ |
Entra ID credential, auth record persistence |
internal/config/ |
Server endpoint map, constants, ~/.a365/config.json support |
internal/output/ |
3-mode formatter, per-entity columns, table/TSV/JSON renderers, HTML stripping, MCP response extraction |
internal/commands/ |
Shared context, auth commands, completion |
internal/commands/<service>/ |
One package per M365 service (18 services + api + config) |
internal/version/ |
Version/commit vars injected via ldflags at build time |