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
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,28 @@ https://learn.microsoft.com/api/mcp?maxTokenBudget=2000
| `microsoft_docs_fetch` | Fetch and convert a Microsoft documentation page into markdown format | `url` (string): URL of the documentation page to read |
| `microsoft_code_sample_search` | Search for official Microsoft/Azure code snippets and examples | `query` (string): Search query for Microsoft/Azure code snippets<br/>`language` (string, optional): Programming language filter.|

## 💻 Companion CLI
## 💻 Microsoft Learn CLI

This repository also includes an in-repo companion CLI. See [`cli/README.md`](cli/README.md) for installation, usage, and full command reference.
The [`@microsoft/learn-cli`](https://www.npmjs.com/package/@microsoft/learn-cli) package gives you terminal access to the same tools — search docs, fetch pages, and find code samples — without an MCP client.

```bash
# Run instantly (no install)
npx @microsoft/learn-cli search "azure functions timeout"

# Or install globally
npm install -g @microsoft/learn-cli
mslearn search "azure functions timeout"
mslearn code-search "BlobServiceClient" --language python
mslearn fetch "https://learn.microsoft.com/azure/azure-functions/functions-versions"
```

Pass `--json` to get raw JSON output for piping to other tools:

```bash
mslearn search "azure openai" --json | jq '.results[].title'
```

See [`cli/README.md`](cli/README.md) for the full command reference.

## 🤖 Agent Skills

Expand Down
61 changes: 28 additions & 33 deletions cli/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Microsoft Learn Companion CLI
# Microsoft Learn CLI

`mslearn` is a thin companion CLI for the public Microsoft Learn MCP server.
`mslearn` is a terminal CLI for the public Microsoft Learn MCP server.

It gives you terminal-friendly commands for docs search, docs fetch, code sample search, and environment diagnostics.

Expand All @@ -18,45 +18,19 @@ This project requires Node.js 22 or later.
node --version
```

## Quick Start
## Installation

From the `cli` directory:
### Option A: Run instantly with `npx` (no install)

```bash
npm install
npm run build
npm test
node dist/index.js --help
```

After building, you can run the CLI in three ways.

### Option A: Run with Node directly

```bash
node dist/index.js search "azure functions timeout"
```

### Option B: Run with `npx`

```bash
npx . search "azure functions timeout"
```

### Option C: Use `npm link` for local CLI-style usage

```bash
npm link
mslearn search "azure functions timeout"
npx @microsoft/learn-cli search "azure functions timeout"
```

After `npm link`, you can use the CLI like a normal command:
### Option B: Install globally

```bash
npm install -g @microsoft/learn-cli
mslearn search "azure functions timeout"
mslearn fetch "https://learn.microsoft.com/azure/azure-functions/functions-versions" --section "Function app timeout duration"
mslearn code-search "cosmos db change feed processor" --language csharp
mslearn doctor
```

## Commands
Expand All @@ -81,6 +55,15 @@ Available commands:
- `code-search <query> --language <name>` searches official code samples.
- `doctor [--format text|json]` checks runtime and connectivity.

The `search` and `code-search` commands output human-readable formatted text by
default. Pass `--json` to get the raw JSON response, which is useful for piping
to other tools:

```bash
mslearn search "azure functions" --json | jq '.results[].title'
mslearn code-search "BlobServiceClient" --language python --json
```

## Endpoint configuration

To override the default endpoint, set `MSLEARN_ENDPOINT` or pass `--endpoint <url>` for a single command.
Expand All @@ -91,3 +74,15 @@ Example in PowerShell:
$env:MSLEARN_ENDPOINT = "https://learn.microsoft.com/api/mcp"
mslearn doctor
```

## Development

To build and test from source:

```bash
cd cli
npm install
npm run build
npm test
node dist/index.js --help
```
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@microsoft/learn-cli",
"version": "0.1.0",
"description": "Thin companion CLI for the Microsoft Learn MCP server.",
"description": "CLI for the Microsoft Learn MCP server.",
"type": "module",
"bin": {
"mslearn": "./dist/index.js"
Expand Down
6 changes: 5 additions & 1 deletion cli/src/commands/code-search.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Command } from 'commander';

import type { CliContext } from '../context.js';
import { formatCodeSearchResults } from '../formatters/search-results.js';
import { resolveEndpoint } from '../utils/options.js';
import { ensureTrailingNewline } from '../utils/text.js';

interface CodeSearchCommandOptions {
language?: string;
json?: boolean;
}

export function registerCodeSearchCommand(program: Command, context: CliContext): void {
Expand All @@ -14,6 +16,7 @@ export function registerCodeSearchCommand(program: Command, context: CliContext)
.description('Search Microsoft Learn code samples through the Learn MCP server.')
.argument('<query>', 'Search query.')
.option('--language <name>', 'Preferred language filter to pass to Learn.')
.option('--json', 'Output raw JSON instead of formatted text.')
.action(async (query: string, options: CodeSearchCommandOptions) => {
const endpoint = resolveEndpoint(program.opts<{ endpoint?: string }>().endpoint, context.env);
const client = context.createClient({
Expand All @@ -24,7 +27,8 @@ export function registerCodeSearchCommand(program: Command, context: CliContext)

try {
const payload = await client.searchCodeSamples(query, options.language);
context.writeOut(ensureTrailingNewline(payload));
const output = options.json ? payload : formatCodeSearchResults(payload);
context.writeOut(ensureTrailingNewline(output));
} finally {
await client.close();
}
Expand Down
11 changes: 9 additions & 2 deletions cli/src/commands/search.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { Command } from 'commander';

import type { CliContext } from '../context.js';
import { formatSearchResults } from '../formatters/search-results.js';
import { resolveEndpoint } from '../utils/options.js';
import { ensureTrailingNewline } from '../utils/text.js';

interface SearchCommandOptions {
json?: boolean;
}

export function registerSearchCommand(program: Command, context: CliContext): void {
program
.command('search')
.description('Search official Microsoft documentation through the Learn MCP server.')
.argument('<query>', 'Search query.')
.action(async (query: string) => {
.option('--json', 'Output raw JSON instead of formatted text.')
.action(async (query: string, options: SearchCommandOptions) => {
const endpoint = resolveEndpoint(program.opts<{ endpoint?: string }>().endpoint, context.env);
const client = context.createClient({
endpoint,
Expand All @@ -19,7 +25,8 @@ export function registerSearchCommand(program: Command, context: CliContext): vo

try {
const payload = await client.searchDocs(query);
context.writeOut(ensureTrailingNewline(payload));
const output = options.json ? payload : formatSearchResults(payload);
context.writeOut(ensureTrailingNewline(output));
} finally {
await client.close();
}
Expand Down
118 changes: 118 additions & 0 deletions cli/src/formatters/search-results.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
const INDENT = ' ';

interface FormatOptions {
/** When true, apply code-search-specific cleanup (description metadata stripping, language suffix). */
codeSearch?: boolean;
}

export function formatSearchResults(payload: string): string {
return formatResultsPayload(payload, { codeSearch: false });
}

export function formatCodeSearchResults(payload: string): string {
return formatResultsPayload(payload, { codeSearch: true });
}
Comment thread
TianqiZhang marked this conversation as resolved.

function formatResultsPayload(payload: string, options: FormatOptions): string {
let data: unknown;
try {
data = JSON.parse(payload);
} catch {
return payload;
}

const results = extractResultsArray(data);
if (!results || results.length === 0) {
return JSON.stringify(data, null, 2);
}

const formatter = options.codeSearch ? formatCodeSearchResult : formatDocsSearchResult;
return results
.map((item, index) => formatter(item as Record<string, unknown>, index + 1))
.join('\n\n');
}

function extractResultsArray(data: unknown): unknown[] | undefined {
if (Array.isArray(data)) {
return data;
}

if (data && typeof data === 'object' && 'results' in data) {
const results = (data as Record<string, unknown>).results;
if (Array.isArray(results)) {
return results;
}
}

return undefined;
}

function formatDocsSearchResult(result: Record<string, unknown>, index: number): string {
const title = stringField(result, 'title') ?? `Result ${index}`;
const url = stringField(result, 'contentUrl') ?? stringField(result, 'url');
const body = stringField(result, 'content');

const lines: string[] = [];
lines.push(`[${index}] ${title}`);

if (url) {
lines.push(`${INDENT}${url}`);
}

if (body) {
lines.push('');
lines.push(body);
}

return lines.join('\n');
}

function formatCodeSearchResult(result: Record<string, unknown>, index: number): string {
const rawTitle = stringField(result, 'description') ?? stringField(result, 'title');
const title = rawTitle ? cleanCodeSearchDescription(rawTitle) : `Result ${index}`;
const url = stringField(result, 'link') ?? stringField(result, 'contentUrl') ?? stringField(result, 'url');
const language = stringField(result, 'language');
const body = stringField(result, 'codeSnippet') ?? stringField(result, 'content');

const lines: string[] = [];

const langSuffix = language ? ` (${language})` : '';
lines.push(`[${index}] ${title}${langSuffix}`);

if (url) {
lines.push(`${INDENT}${url}`);
}

if (body) {
lines.push('');
lines.push(body);
}

return lines.join('\n');
}

/**
* The Learn MCP code-search description field sometimes embeds structured
* metadata lines (e.g. "description: …\npackage: …\nlanguage: …\n").
* Strip those so the title line stays clean.
*/
function cleanCodeSearchDescription(raw: string): string {
let text = raw;

// Strip leading "description: " prefix (case-insensitive).
text = text.replace(/^\s*description:\s*/i, '');

// Drop trailing metadata lines like "package: ..." and "language: ...".
text = text
.split(/\r?\n/)
.filter((line) => !/^\s*(package|language):\s*/i.test(line))
.join(' ')
.trim();

return text || raw;
}

function stringField(obj: Record<string, unknown>, key: string): string | undefined {
const value = obj[key];
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
2 changes: 1 addition & 1 deletion cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function createProgram(context: CliContext): Command {

program
.name('mslearn')
.description('Thin companion CLI for the Microsoft Learn MCP server.')
.description('CLI for the Microsoft Learn MCP server.')
.version(context.version)
.addOption(new Option('--endpoint <url>', 'Override the Learn MCP endpoint for this command.').hideHelp())
.showHelpAfterError()
Expand Down
41 changes: 32 additions & 9 deletions cli/test/unit/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('runCli', () => {
expect(stdout.join('')).not.toContain('--endpoint <url>');
});

it('returns the raw search payload', async () => {
it('formats search results with one result per block', async () => {
const client = createMockClient({
searchDocs: vi
.fn()
Expand All @@ -69,15 +69,38 @@ describe('runCli', () => {
const exitCode = await runCli(['node', 'mslearn', 'search', 'azure functions timeout'], context);

expect(exitCode).toBe(0);
expect(JSON.parse(stdout.join(''))).toEqual({
results: [
{
title: 'Azure Functions runtime versions overview',
contentUrl: 'https://learn.microsoft.com/example',
content: 'The functionTimeout property in host.json sets the timeout duration.',
},
],
const output = stdout.join('');
expect(output).toContain('[1] Azure Functions runtime versions overview');
expect(output).toContain('https://learn.microsoft.com/example');
expect(output).toContain('The functionTimeout property in host.json sets the timeout duration.');
});

it('outputs raw JSON from search when --json is passed', async () => {
const rawPayload =
'{"results":[{"title":"Test","contentUrl":"https://learn.microsoft.com/example","content":"Body."}]}';
const client = createMockClient({
searchDocs: vi.fn().mockResolvedValue(rawPayload),
});
const { context, stdout } = createTestContext(client);

const exitCode = await runCli(['node', 'mslearn', 'search', 'test query', '--json'], context);

expect(exitCode).toBe(0);
expect(JSON.parse(stdout.join(''))).toEqual(JSON.parse(rawPayload));
});

it('outputs raw JSON from code-search when --json is passed', async () => {
const rawPayload =
'{"results":[{"description":"desc","codeSnippet":"x = 1","link":"https://example.com","language":"python"}]}';
const client = createMockClient({
searchCodeSamples: vi.fn().mockResolvedValue(rawPayload),
});
const { context, stdout } = createTestContext(client);

const exitCode = await runCli(['node', 'mslearn', 'code-search', 'test query', '--json'], context);

expect(exitCode).toBe(0);
expect(JSON.parse(stdout.join(''))).toEqual(JSON.parse(rawPayload));
});

it('filters fetched markdown by section', async () => {
Expand Down
Loading
Loading