msteams-mcp 0.29.2 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Command and argument resolution for the `msteams` CLI.
3
+ *
4
+ * Everything here is derived from the MCP tool definitions - their names and
5
+ * JSON input schemas - so a new tool is callable, documented and type-coerced
6
+ * without touching the CLI.
7
+ */
8
+ import { parseArgs } from 'node:util';
9
+ /** Thrown for anything the user can fix by retyping the command (exit code 2). */
10
+ export class UsageError extends Error {
11
+ }
12
+ /** Flag aliases that read better than the raw parameter name. */
13
+ export const FLAG_ALIASES = {
14
+ to: 'conversationId',
15
+ msg: 'messageId',
16
+ message: 'messageId',
17
+ reply: 'replyToMessageId',
18
+ replyTo: 'replyToMessageId',
19
+ user: 'userId',
20
+ force: 'forceNew',
21
+ };
22
+ /** Flags accepted for every command. */
23
+ const GLOBAL_FLAGS = {
24
+ json: { type: 'boolean' },
25
+ help: { type: 'boolean', short: 'h' },
26
+ };
27
+ export function schemaProps(tool) {
28
+ return (tool.inputSchema.properties ?? {});
29
+ }
30
+ export function requiredProps(tool) {
31
+ return (tool.inputSchema.required ?? []);
32
+ }
33
+ /** The parameter a bare value after the command maps to: the first required string. */
34
+ export function positionalProp(tool) {
35
+ const props = schemaProps(tool);
36
+ return requiredProps(tool).find(name => (props[name]?.type ?? 'string') === 'string') ?? null;
37
+ }
38
+ const normalise = (name) => name.replace(/_/g, '').toLowerCase();
39
+ /**
40
+ * Resolves a typed command to a tool name.
41
+ *
42
+ * Tries, in order: the exact name, `teams_<cmd>`, `teams_get_<cmd>`,
43
+ * `teams_search_<cmd>`, a unique `teams_<cmd>*` prefix, then a unique
44
+ * substring match ignoring underscores. So `search`, `get_thread`, `thread`,
45
+ * `send` and `markread` all land on the right tool.
46
+ */
47
+ export function resolveToolName(names, command) {
48
+ const cmd = command.toLowerCase();
49
+ const exact = [cmd, `teams_${cmd}`, `teams_get_${cmd}`, `teams_search_${cmd}`]
50
+ .find(name => names.includes(name));
51
+ if (exact)
52
+ return exact;
53
+ const prefixed = names.filter(name => name.startsWith(`teams_${cmd}`));
54
+ if (prefixed.length === 1)
55
+ return prefixed[0];
56
+ const fuzzy = names.filter(name => normalise(name).includes(normalise(cmd)));
57
+ if (fuzzy.length === 1)
58
+ return fuzzy[0];
59
+ const candidates = prefixed.length ? prefixed : fuzzy;
60
+ throw new UsageError(candidates.length
61
+ ? `Ambiguous command '${command}' - matches ${candidates.join(', ')}.`
62
+ : `Unknown command '${command}'. Run 'msteams help' for the command list.`);
63
+ }
64
+ /** The shortest form that still resolves back to this tool - used in help output. */
65
+ export function shortName(names, toolName) {
66
+ const candidates = [
67
+ toolName.replace(/^teams_(get_|search_|list_|find_)?/, ''),
68
+ toolName.replace(/^teams_/, ''),
69
+ ];
70
+ for (const candidate of candidates) {
71
+ if (!candidate)
72
+ continue;
73
+ try {
74
+ if (resolveToolName(names, candidate) === toolName)
75
+ return candidate;
76
+ }
77
+ catch {
78
+ // Ambiguous - fall through to the next candidate.
79
+ }
80
+ }
81
+ return toolName;
82
+ }
83
+ /** Coerces one raw flag value using the schema's declared type. */
84
+ function coerce(prop, raw, flag) {
85
+ const type = prop?.type ?? 'string';
86
+ if (type === 'number' || type === 'integer') {
87
+ const value = Number(raw);
88
+ if (!Number.isFinite(value))
89
+ throw new UsageError(`--${flag} expects a number, got '${raw}'`);
90
+ return value;
91
+ }
92
+ if (type === 'array') {
93
+ const items = raw.trim().startsWith('[')
94
+ ? parseJson(raw, flag)
95
+ : raw.split(',').map(part => part.trim()).filter(Boolean);
96
+ return prop?.items?.type === 'number' ? items.map(Number) : items;
97
+ }
98
+ if (type === 'object')
99
+ return parseJson(raw, flag);
100
+ return raw;
101
+ }
102
+ function parseJson(raw, flag) {
103
+ try {
104
+ return JSON.parse(raw);
105
+ }
106
+ catch {
107
+ throw new UsageError(`--${flag} expects JSON, got '${raw}'`);
108
+ }
109
+ }
110
+ /** One-line reminder of a command's flags, used in every usage error. */
111
+ function flagHint(props, label) {
112
+ const flags = Object.keys(props).map(name => `--${name}`).join(' ') || '(none)';
113
+ return `Flags for '${label}': ${flags}\nRun 'msteams help ${label}' for details.`;
114
+ }
115
+ /**
116
+ * Parses a command's arguments against its input schema.
117
+ *
118
+ * Flags are whatever the schema declares (plus the aliases above), values are
119
+ * coerced to the schema's types, and an unknown flag is a usage error rather
120
+ * than something the server has to reject.
121
+ */
122
+ export function parseToolArgs(tool, argv, label = tool.name) {
123
+ const props = schemaProps(tool);
124
+ const options = { ...GLOBAL_FLAGS };
125
+ const optionType = (prop) => prop?.type === 'boolean' ? 'boolean' : 'string';
126
+ for (const [name, prop] of Object.entries(props)) {
127
+ options[name] = { type: optionType(prop) };
128
+ }
129
+ for (const [alias, target] of Object.entries(FLAG_ALIASES)) {
130
+ if (props[target] && !options[alias])
131
+ options[alias] = { type: optionType(props[target]) };
132
+ }
133
+ // `--flag true|false` stays supported for boolean flags (it is how the docs
134
+ // and the MCP skill spell them), so take the value here rather than letting
135
+ // parseArgs treat it as a positional.
136
+ const explicitBooleans = {};
137
+ const tokens = [];
138
+ for (let i = 0; i < argv.length; i++) {
139
+ const token = argv[i];
140
+ const name = token.startsWith('--') ? token.slice(2) : '';
141
+ const next = argv[i + 1];
142
+ if (options[name]?.type === 'boolean' && (next === 'true' || next === 'false')) {
143
+ explicitBooleans[FLAG_ALIASES[name] ?? name] = next === 'true';
144
+ i++;
145
+ continue;
146
+ }
147
+ tokens.push(token);
148
+ }
149
+ let parsed;
150
+ try {
151
+ parsed = parseArgs({ args: tokens, options, allowPositionals: true, strict: true });
152
+ }
153
+ catch (error) {
154
+ // parseArgs messages carry a long tail about '--'; the first sentence is the useful part.
155
+ const reason = (error instanceof Error ? error.message : String(error)).split('. ')[0];
156
+ throw new UsageError(`${reason}.\n${flagHint(props, label)}`);
157
+ }
158
+ // `--help` only wants to read about the command, so skip validation.
159
+ if (parsed.values.help) {
160
+ return { args: {}, json: Boolean(parsed.values.json), help: true };
161
+ }
162
+ const args = {};
163
+ for (const [flag, raw] of Object.entries(parsed.values)) {
164
+ if (flag === 'json' || flag === 'help')
165
+ continue;
166
+ const key = FLAG_ALIASES[flag] ?? flag;
167
+ args[key] = typeof raw === 'boolean' ? raw : coerce(props[key], String(raw), flag);
168
+ }
169
+ Object.assign(args, explicitBooleans);
170
+ const missing = requiredProps(tool).filter(name => args[name] === undefined);
171
+ const positional = positionalProp(tool);
172
+ const [value, ...extra] = parsed.positionals;
173
+ if (extra.length) {
174
+ throw new UsageError(`Unexpected argument '${extra[0]}'. Run 'msteams help ${label}'.`);
175
+ }
176
+ if (value !== undefined) {
177
+ if (!positional) {
178
+ throw new UsageError(`'${label}' takes no value. Run 'msteams help ${label}' for its flags.`);
179
+ }
180
+ if (args[positional] === undefined) {
181
+ args[positional] = coerce(props[positional], value, positional);
182
+ missing.splice(missing.indexOf(positional), 1);
183
+ }
184
+ }
185
+ if (missing.length > 0) {
186
+ const positionalNote = positional && missing.includes(positional)
187
+ ? ` (${positional} can be given as the first value: msteams ${label} "...")`
188
+ : '';
189
+ throw new UsageError(`Missing required ${missing.map(name => `--${name}`).join(', ')}${positionalNote}.\n` +
190
+ flagHint(props, label));
191
+ }
192
+ return {
193
+ args,
194
+ json: Boolean(parsed.values.json),
195
+ help: Boolean(parsed.values.help),
196
+ };
197
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * CLI resolution tests.
3
+ *
4
+ * These run against the real tool definitions, so they also guard the
5
+ * assumption the CLI rests on: that commands, flags and positional arguments
6
+ * can be derived from the schemas.
7
+ */
8
+ export {};
@@ -0,0 +1,118 @@
1
+ /**
2
+ * CLI resolution tests.
3
+ *
4
+ * These run against the real tool definitions, so they also guard the
5
+ * assumption the CLI rests on: that commands, flags and positional arguments
6
+ * can be derived from the schemas.
7
+ */
8
+ import { describe, it, expect } from 'vitest';
9
+ import { getToolDefinitions } from '../tools/registry.js';
10
+ import { UsageError, parseToolArgs, positionalProp, resolveToolName, shortName, } from './resolve.js';
11
+ import { firstSentence } from './help.js';
12
+ const tools = getToolDefinitions();
13
+ const names = tools.map(tool => tool.name);
14
+ const tool = (name) => tools.find(t => t.name === name);
15
+ describe('resolveToolName', () => {
16
+ it.each([
17
+ ['teams_search', 'teams_search'],
18
+ ['search', 'teams_search'],
19
+ ['thread', 'teams_get_thread'],
20
+ ['get_thread', 'teams_get_thread'],
21
+ ['people', 'teams_search_people'],
22
+ ['send', 'teams_send_message'],
23
+ ['send_message', 'teams_send_message'],
24
+ ['save', 'teams_save_message'],
25
+ ['unsave', 'teams_unsave_message'],
26
+ ['markread', 'teams_mark_read'],
27
+ ['channel', 'teams_find_channel'],
28
+ ['chat', 'teams_get_chat'],
29
+ ['chats', 'teams_list_chats'],
30
+ ['message', 'teams_get_message'],
31
+ ['assignment', 'teams_get_assignment'],
32
+ ['assignments', 'teams_list_assignments'],
33
+ ['SEARCH', 'teams_search'],
34
+ ])('resolves %s', (command, expected) => {
35
+ expect(resolveToolName(names, command)).toBe(expected);
36
+ });
37
+ it('rejects an unknown command', () => {
38
+ expect(() => resolveToolName(names, 'nope')).toThrow(UsageError);
39
+ });
40
+ it('rejects an ambiguous command and names the candidates', () => {
41
+ expect(() => resolveToolName(names, 'reaction')).toThrow(/teams_add_reaction/);
42
+ });
43
+ });
44
+ describe('shortName', () => {
45
+ it('gives every tool a label that resolves back to it', () => {
46
+ for (const name of names) {
47
+ expect(resolveToolName(names, shortName(names, name))).toBe(name);
48
+ }
49
+ });
50
+ });
51
+ describe('positionalProp', () => {
52
+ it('is the first required string', () => {
53
+ expect(positionalProp(tool('teams_search'))).toBe('query');
54
+ expect(positionalProp(tool('teams_send_message'))).toBe('content');
55
+ });
56
+ it('is absent when nothing is required', () => {
57
+ expect(positionalProp(tool('teams_get_favorites'))).toBeNull();
58
+ });
59
+ });
60
+ describe('parseToolArgs', () => {
61
+ const parse = (name, argv) => parseToolArgs(tool(name), argv);
62
+ it('maps a bare value to the positional parameter', () => {
63
+ expect(parse('teams_search', ['hello']).args).toEqual({ query: 'hello' });
64
+ });
65
+ it('coerces numbers from the schema type and leaves ids as strings', () => {
66
+ const { args } = parse('teams_get_thread', ['--conversationId', '19:abc', '--limit', '25']);
67
+ expect(args).toEqual({ conversationId: '19:abc', limit: 25 });
68
+ });
69
+ it('accepts booleans as a bare flag and as --flag true|false', () => {
70
+ expect(parse('teams_get_thread', ['--markRead']).args.markRead).toBe(true);
71
+ expect(parse('teams_get_thread', ['--markRead', 'true']).args.markRead).toBe(true);
72
+ expect(parse('teams_get_thread', ['--markRead', 'false']).args.markRead).toBe(false);
73
+ });
74
+ it('parses arrays as JSON or a comma-separated list', () => {
75
+ expect(parse('teams_create_group_chat', ['--userIds', '["a","b"]']).args.userIds).toEqual(['a', 'b']);
76
+ expect(parse('teams_create_group_chat', ['--userIds', 'a, b']).args.userIds).toEqual(['a', 'b']);
77
+ });
78
+ it('applies flag aliases', () => {
79
+ const { args } = parse('teams_send_message', ['hi', '--to', '19:abc']);
80
+ expect(args).toEqual({ content: 'hi', conversationId: '19:abc' });
81
+ });
82
+ it('prefers an explicit flag over the positional', () => {
83
+ expect(parse('teams_search', ['bare', '--query', 'flag']).args.query).toBe('flag');
84
+ });
85
+ it('extracts the global flags without passing them to the tool', () => {
86
+ const parsed = parse('teams_search', ['hi', '--json']);
87
+ expect(parsed.json).toBe(true);
88
+ expect(parsed.args).toEqual({ query: 'hi' });
89
+ expect(parse('teams_search', ['--help']).help).toBe(true);
90
+ });
91
+ it.each([
92
+ ['an unknown flag', 'teams_search', ['hi', '--nope', 'x']],
93
+ ['a non-numeric number', 'teams_search', ['hi', '--size', 'abc']],
94
+ ['a missing required parameter', 'teams_send_message', []],
95
+ ['a second bare value', 'teams_search', ['one', 'two']],
96
+ ['a value for a command that takes none', 'teams_get_favorites', ['oops']],
97
+ ])('rejects %s', (_label, name, argv) => {
98
+ expect(() => parse(name, argv)).toThrow(UsageError);
99
+ });
100
+ it('does not require parameters that --help only wants to read about', () => {
101
+ expect(parse('teams_send_message', ['--help']).help).toBe(true);
102
+ });
103
+ });
104
+ describe('firstSentence', () => {
105
+ it('stops at the end of the first sentence', () => {
106
+ expect(firstSentence('Find Teams channels by name. Searches both (1) channels...'))
107
+ .toBe('Find Teams channels by name');
108
+ });
109
+ it('does not treat an abbreviation as the end', () => {
110
+ expect(firstSentence('Get profiles by MRI (e.g. "8:orgid:uuid"). Returns display name.'))
111
+ .toBe('Get profiles by MRI (e.g. "8:orgid:uuid")');
112
+ });
113
+ it('gives every tool a non-empty summary', () => {
114
+ for (const t of tools) {
115
+ expect(firstSentence(t.description ?? '').length).toBeGreaterThan(5);
116
+ }
117
+ });
118
+ });
package/dist/cli.d.ts CHANGED
@@ -1,20 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Teams MCP CLI
3
+ * Teams CLI
4
4
  *
5
- * A standalone command-line interface with full parity to the MCP server.
6
- * It runs the same in-process server over an in-memory transport and speaks
7
- * to it through the real MCP protocol, so every registered tool is callable.
5
+ * A standalone command-line interface with full parity to the MCP server. It
6
+ * runs the same in-process server over an in-memory transport and speaks to it
7
+ * through the real MCP protocol, so every registered tool is callable.
8
8
  *
9
- * Usage (installed):
10
- * msteams # List tools and check status
11
- * msteams search "query" # Search for messages (shortcut)
12
- * msteams teams_search --query "q" # Generic tool call
13
- * msteams --json # Output as JSON
9
+ * msteams # command list
10
+ * msteams help search # detail for one command
11
+ * msteams search "query" # call a tool
12
+ * msteams get_thread --to "<id>" # flags come from the tool's schema
13
+ * msteams search "query" --json # machine-readable output
14
14
  *
15
- * Usage (from the repo):
16
- * npm run cli -- search "query"
15
+ * Commands, flags, type coercion and help are all derived from the tool
16
+ * definitions, so a new tool needs no changes here.
17
17
  *
18
- * Any unrecognised command is treated as a tool name. Use --key value for parameters.
18
+ * Exit codes: 0 success, 1 the tool failed, 2 the command was malformed.
19
19
  */
20
20
  export {};