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
18 changes: 9 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,13 @@ func (c *MyCmd) Run(ctx *commands.Context) error {
if ctx.DryRun { return ctx.ValidateDryRun(endpoint(), "ToolName", "do X", displayData, mcpArgs) }
// 2. Confirm guard (destructive ops only)
if err := ctx.Confirm("delete X"); err != nil { return err }
// 3. Create client + initialize
client := ctx.NewMCPClient(endpoint())
if err := client.Initialize(ctx.Ctx); err != nil { ... }
// 4. Call MCP tool
resp, err := client.CallTool(ctx.Ctx, "ToolName", map[string]any{...})
// 5. Extract + print
data, err := output.ExtractContent(resp)
return ctx.Output.PrintList("items", output.SomeColumns, rows) // or PrintItem/PrintMutation
// 3. Build MCP args
args := map[string]any{...}
// 4. Call MCP tool + extract content via the shared execution seam
data, err := ctx.CallToolData(endpoint(), "ToolName", "do X", args)
if err != nil { return err }
// 5. Print
return ctx.Output.PrintListFromData("items", output.SomeColumns, data, 0, "items", "value") // or PrintItem/PrintMutation
}
```

Expand All @@ -44,7 +43,8 @@ Adding a new service = new directory in `internal/commands/`, register in `main.
## Key Conventions

- Use `config.Endpoint("service")` for server URLs, never hardcode
- Use `output.ExtractContent()` then `ToRows()` for list data
- Use `ctx.CallToolData()` for the normal initialize/call/extract path; reserve `ctx.NewMCPClient()` for custom protocol flows such as `tools/list`
- Use `ctx.Output.PrintListFromData()` for standard list extraction/fallback/limit handling
- Write ops: always add `--dry-run` guard with `ctx.ValidateDryRun(endpoint, toolName, action, displayData, mcpArgs)`
- When display keys differ from MCP arg keys, pass mcpArgs as the 5th parameter for correct validation
- Destructive ops: add `ctx.Confirm()` after dry-run check
Expand Down
23 changes: 3 additions & 20 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,28 +83,11 @@ type MyServiceListCmd struct {
}

func (c *MyServiceListCmd) Run(ctx *commands.Context) error {
client := ctx.NewMCPClient(endpoint())
if err := client.Initialize(ctx.Ctx); err != nil {
return fmt.Errorf("initialize: %w", err)
}

resp, err := client.CallTool(ctx.Ctx, "ListItems", map[string]any{})
if err != nil {
return fmt.Errorf("list items: %w", err)
}

data, err := output.ExtractContent(resp)
data, err := ctx.CallToolData(endpoint(), "ListItems", "list items", map[string]any{})
if err != nil {
return err
}
rows := output.ToRows(data, "items")
if rows == nil {
rows = output.ToRows(data, "value")
}
if rows == nil {
return ctx.Output.PrintItem(data)
}
return ctx.Output.PrintList("items", output.MyColumns, rows)
return ctx.Output.PrintListFromData("items", output.MyColumns, data, c.Max, "items", "value")
}
```

Expand Down Expand Up @@ -148,7 +131,7 @@ go test ./internal/mcp/... -v # MCP client tests only
go test ./internal/output/... -v # Output formatting tests only
```

The test suite uses `httptest.NewServer` for MCP client tests and `bytes.Buffer` injection for output formatter tests. Use `testutil.SetupTestServerWithSchemas` for dry-run tests that verify schema validation. No real network calls in tests.
The test suite uses `httptest.NewServer` for MCP client tests, `commands.Context.CallToolData` tests for command execution, and `bytes.Buffer` injection for output formatter tests. Use `testutil.SetupTestServerWithSchemas` for dry-run tests that verify schema validation. No real network calls in tests.

## Discovering MCP Tools

Expand Down
14 changes: 8 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ Every command follows the same path:
5. **Request** — `CallTool()` sends a JSON-RPC `tools/call` POST with `Authorization: Bearer` and `Mcp-Session-Id` headers
6. **Retry** — on 502/503/429/504, retries up to 2x with exponential backoff (1s, 2s); respects `Retry-After` header
7. **Response** — parses SSE stream (`data:` lines) or plain JSON; extracts the first JSON-RPC message
8. **Extract** — `ExtractContent()` unwraps 3 response patterns (clean JSON, embedded JSON after status text, rawResponse-wrapped)
9. **Render** — `PrintList`/`PrintItem`/`PrintMutation` dispatches to table (tabwriter), JSON, or TSV based on `--output`
8. **Command execution seam** — command handlers use `Context.CallToolData()` to keep initialize → tool call → content extraction and action-scoped errors in one module
9. **Extract** — `ExtractContent()` unwraps 3 response patterns (clean JSON, embedded JSON after status text, rawResponse-wrapped)
10. **Render** — `PrintListFromData`/`PrintList`/`PrintItem`/`PrintMutation` dispatches to table (tabwriter), JSON, or TSV based on `--output`

## MCP Protocol

Expand Down Expand Up @@ -95,10 +96,11 @@ Browser ──PKCE──► Entra ID (login.microsoftonline.com)
## Output Pipeline

```
MCP JSONRPCResponse
→ ExtractContent() (extract.go) → map[string]any
→ ToRows() (extract.go) → []map[string]any
→ PrintList() (formatter.go) → format dispatch
Command handler
→ Context.CallToolData() (commands/root.go) → MCP JSONRPCResponse
→ ExtractContent() (extract.go) → map[string]any
→ PrintListFromData() (formatter.go) → ToRows() + fallback + max
→ PrintList() (formatter.go) → format dispatch
├── FormatHuman → RenderTable() (render.go) → text/tabwriter
├── FormatJSON → writeJSON() (formatter.go) → json.Encoder
└── FormatPlain → RenderTSV() (render.go) → raw tabs
Expand Down
16 changes: 9 additions & 7 deletions docs/excel.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@ Create, read, and collaborate on Microsoft Excel workbooks. Supports creating ne

| Command | Description | Key Arguments |
|---------|-------------|---------------|
| `excel create` | Create a new Excel workbook | `<file-name>` |
| `excel get` | Get workbook content | `<drive-id> <document-id>` |
| `excel create` | Create a new Excel workbook | `<file-name>` `--csv-content` |
| `excel get` | Get workbook content | `<url>` |
| `excel comment` | Add a comment to a workbook cell | `<drive-id> <document-id> <cell-address> <text>` |
| `excel reply` | Reply to a workbook comment | `<comment-id> <drive-id> <document-id> <text>` |

## Arguments

- **`<file-name>`** -- Desired file name for the new workbook.
- **`<drive-id>`** -- OneDrive or SharePoint drive ID.
- **`<document-id>`** -- Document ID within the drive.
- **`--csv-content`** -- CSV content used to populate a newly created workbook (defaults to empty).
- **`<url>`** -- SharePoint sharing URL for the workbook.
- **`<drive-id>`** -- OneDrive or SharePoint drive ID (used by comment/reply).
- **`<document-id>`** -- Document ID within the drive (used by comment/reply).
- **`<cell-address>`** -- Cell address for the comment (e.g. `A1`, `B2`, `C10`).
- **`<comment-id>`** -- ID of the comment to reply to.
- **`<text>`** -- Comment or reply text.
Expand All @@ -25,13 +27,13 @@ Create, read, and collaborate on Microsoft Excel workbooks. Supports creating ne

```sh
# Create a new workbook
a365 excel create "Q3 Budget.xlsx"
a365 excel create "Q3 Budget.xlsx" --csv-content $'category,amount\ntravel,100'

# Preview creation without making changes
a365 excel create "Expenses.xlsx" --dry-run

# Get workbook content
a365 excel get b!xYzDriveId01 01ABCDEF23456789
a365 excel get "https://contoso.sharepoint.com/sites/finance/Shared%20Documents/Budget.xlsx"

# Add a comment at cell B5
a365 excel comment b!xYzDriveId01 01ABCDEF23456789 B5 "This value looks off"
Expand All @@ -43,5 +45,5 @@ a365 excel comment b!xYzDriveId01 01ABCDEF23456789 A1 "Check formula" --dry-run
a365 excel reply comment-id-789 b!xYzDriveId01 01ABCDEF23456789 "Fixed the formula"

# Output as JSON
a365 excel get b!xYzDriveId01 01ABCDEF23456789 --output json
a365 excel get "https://contoso.sharepoint.com/sites/finance/Shared%20Documents/Budget.xlsx" --output json
```
16 changes: 9 additions & 7 deletions docs/word.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@ Create, read, and collaborate on Microsoft Word documents. Supports creating new

| Command | Description | Key Arguments |
|---------|-------------|---------------|
| `word create` | Create a new Word document | `<file-name>` |
| `word get` | Get document content | `<drive-id> <document-id>` |
| `word create` | Create a new Word document | `<file-name>` `--content` |
| `word get` | Get document content | `<url>` |
| `word comment` | Add a comment to a document | `<drive-id> <document-id> <text>` |
| `word reply` | Reply to a document comment | `<comment-id> <drive-id> <document-id> <text>` |

## Arguments

- **`<file-name>`** -- Desired file name for the new document.
- **`<drive-id>`** -- OneDrive or SharePoint drive ID.
- **`<document-id>`** -- Document ID within the drive.
- **`--content`** -- HTML or plain text content for the document body (defaults to empty).
- **`<url>`** -- SharePoint sharing URL for the document.
- **`<drive-id>`** -- OneDrive or SharePoint drive ID (used by comment/reply).
- **`<document-id>`** -- Document ID within the drive (used by comment/reply).
- **`<comment-id>`** -- ID of the comment to reply to.
- **`<text>`** -- Comment or reply text.
- **`--dry-run`** -- Preview write operations without executing them (supported by `create`, `comment`, and `reply`).
Expand All @@ -24,13 +26,13 @@ Create, read, and collaborate on Microsoft Word documents. Supports creating new

```sh
# Create a new document
a365 word create "Project Proposal.docx"
a365 word create "Project Proposal.docx" --content "<h1>Project Proposal</h1>"

# Preview creation without making changes
a365 word create "Draft Notes.docx" --dry-run

# Get document content
a365 word get b!xYzDriveId01 01ABCDEF23456789
a365 word get "https://contoso.sharepoint.com/sites/project/Shared%20Documents/Proposal.docx"

# Add a comment to a document
a365 word comment b!xYzDriveId01 01ABCDEF23456789 "Please review section 3"
Expand All @@ -42,5 +44,5 @@ a365 word reply comment-id-456 b!xYzDriveId01 01ABCDEF23456789 "Done, updated."
a365 word reply comment-id-456 b!xYzDriveId01 01ABCDEF23456789 "Looks good" --dry-run

# Output as JSON
a365 word get b!xYzDriveId01 01ABCDEF23456789 --output json
a365 word get "https://contoso.sharepoint.com/sites/project/Shared%20Documents/Proposal.docx" --output json
```
37 changes: 3 additions & 34 deletions internal/commands/admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (

"github.com/sozercan/a365cli/internal/commands"
"github.com/sozercan/a365cli/internal/config"
"github.com/sozercan/a365cli/internal/output"
)

// AdminCmd groups M365 admin subcommands.
Expand All @@ -25,20 +24,10 @@ type AdminSearchUsersCmd struct {
}

func (c *AdminSearchUsersCmd) Run(ctx *commands.Context) error {
client := ctx.NewMCPClient(adminEndpoint())
if err := client.Initialize(ctx.Ctx); err != nil {
return fmt.Errorf("initialize: %w", err)
}

resp, err := client.CallTool(ctx.Ctx, "mcp_Admin365_SearchUserTools", map[string]any{
data, err := ctx.CallToolData(adminEndpoint(), "mcp_Admin365_SearchUserTools", "search users", map[string]any{
"searchTerm": c.Query,
"ConsistencyLevel": "eventual",
})
if err != nil {
return fmt.Errorf("search users: %w", err)
}

data, err := output.ExtractContent(resp)
if err != nil {
return err
}
Expand All @@ -49,17 +38,7 @@ func (c *AdminSearchUsersCmd) Run(ctx *commands.Context) error {
type AdminListLicensesCmd struct{}

func (c *AdminListLicensesCmd) Run(ctx *commands.Context) error {
client := ctx.NewMCPClient(adminEndpoint())
if err := client.Initialize(ctx.Ctx); err != nil {
return fmt.Errorf("initialize: %w", err)
}

resp, err := client.CallTool(ctx.Ctx, "mcp_Admin365_ListLicenseTools", map[string]any{})
if err != nil {
return fmt.Errorf("list licenses: %w", err)
}

data, err := output.ExtractContent(resp)
data, err := ctx.CallToolData(adminEndpoint(), "mcp_Admin365_ListLicenseTools", "list licenses", map[string]any{})
if err != nil {
return err
}
Expand All @@ -86,26 +65,16 @@ func (c *AdminSetLicenseCmd) Run(ctx *commands.Context) error {
)
}

client := ctx.NewMCPClient(adminEndpoint())
if err := client.Initialize(ctx.Ctx); err != nil {
return fmt.Errorf("initialize: %w", err)
}

addList := make([]map[string]any, 0, len(c.AddLicenses))
for _, sku := range c.AddLicenses {
addList = append(addList, map[string]any{"skuId": sku})
}

resp, err := client.CallTool(ctx.Ctx, "mcp_Admin365_LicenseMgmtTools", map[string]any{
data, err := ctx.CallToolData(adminEndpoint(), "mcp_Admin365_LicenseMgmtTools", "set license", map[string]any{
"userId": c.UserID,
"addLicenses": addList,
"removeLicenses": c.RemoveLicenses,
})
if err != nil {
return fmt.Errorf("set license: %w", err)
}

data, err := output.ExtractContent(resp)
if err != nil {
return err
}
Expand Down
Loading