@ian-pascoe/pi-mcp-selector 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # Pi MCP Selector
2
2
 
3
- Point the model at MCP servers with `@server-name` anywhere in a user message.
4
- Type `@` to open Pi's native completion popup: your MCP servers are listed first,
5
- then the usual file results. Selecting a server leaves shorthand in the editor.
6
- Repeat to reference several servers.
3
+ Point the model at MCP servers with `&server-name` anywhere in a user message.
4
+ Type `&` to open a completion popup listing your MCP servers. Selecting a server
5
+ leaves shorthand in the editor. Repeat to reference several servers. `@` stays
6
+ Pi's file completion and is not used.
7
7
 
8
8
  ```bash
9
9
  pi install npm:@ian-pascoe/pi-mcp-selector
10
10
  ```
11
11
 
12
12
  Requires Pi `>=1.1.0` and Node `>=22.19.0`. There are no settings or extra tools.
13
- Prose Span detection comes from
13
+ Prose Span detection and the completion popup stack come from
14
14
  [`@ian-pascoe/pi-utils/prose-references`](../pi-utils/README.md#prose-references).
15
15
 
16
16
  ## Submission
@@ -18,8 +18,8 @@ Prose Span detection comes from
18
18
  Typed, pasted, and selected references are equivalent. On submission:
19
19
 
20
20
  ```text
21
- Check open issues with @github.
22
- → Check open issues with [@github](mcp://github "MCP server mcp__github").
21
+ Check open issues with &github.
22
+ → Check open issues with [&github](mcp://github "MCP server mcp__github").
23
23
  ```
24
24
 
25
25
  The link target is the configured server name and its title is the namespace
@@ -30,55 +30,62 @@ prompt, and tool declarations unchanged. The submitted transcript stores the lin
30
30
  rather than a separate raw shorthand message.
31
31
 
32
32
  Only exact known names in prose convert. Names are case-sensitive, and `-` and
33
- `_` are interchangeable, as they are in Pi's tool names (`@dev_radius` reaches
33
+ `_` are interchangeable, as they are in Pi's tool names (`&dev_radius` reaches
34
34
  `dev-radius`). These stay literal:
35
35
 
36
- - partial or unknown names, `foo@github`, `@@github`, and `\@github`
37
- - anything shaped like a path, address, or URI: `@github/x`, `@github.com`,
38
- `@./github`, and `@github:resource` (a colon not followed by a space)
36
+ - partial or unknown names, `foo&github`, `&&github`, and `\&github`
37
+ - text that merely contains an ampersand: `R&D`, `Q&A`, `a && b`, `Q & A`, and
38
+ Rust's `&self` or `&str` when no server has that name
39
+ - HTML entities, even when a server has the entity's name: `&`, ` `, and
40
+ `&github` stay literal for servers named `amp` or `nbsp` (a name directly
41
+ followed by `;` never converts)
42
+ - anything shaped like a path, address, or URI: `&github/x`, `&github.com`,
43
+ `&./github`, and `&github:resource` (a colon not followed by a space)
39
44
  - code, existing Markdown links, and HTML
40
45
 
41
- Sentence punctuation does not block a reference: `@github's`, `(@github)`,
42
- `@github.`, and `@github: list issues` all convert. Reuse of existing links
46
+ Sentence punctuation does not block a reference: `&github's`, `(&github)`,
47
+ `&github.`, and `&github: list issues` all convert. Reuse of existing links
43
48
  is safe, so submitting converted text again changes nothing.
44
49
 
45
50
  ## The MCP Server Catalogue
46
51
 
47
52
  Both the popup and conversion read Pi's current tools, fresh on every popup and
48
- every submission. A server is in the catalogue when it is enabled, connected, and
53
+ every submission. A server is in the Catalogue when it is enabled, connected, and
49
54
  has at least one tool that is not `hidden`, whether it comes from `mcp.json` or an
50
55
  extension's `registerMcpServer()`. Its exposure (`codemode`, `deferred`, `direct`)
51
56
  does not matter, and neither does whether `codemode` is active.
52
57
 
53
58
  - A disabled server re-registers its tools as hidden, so it is absent.
54
59
  - A server that is still connecting or needs sign-in has no tools yet, so it is
55
- absent and its `@name` stays literal, silently. In `pi -p`, the first prompt's
60
+ absent and its `&name` stays literal, silently. In `pi -p`, the first prompt's
56
61
  reference usually stays literal while servers connect.
57
62
  - The popup describes each server as `MCP server · <description>`, using the
58
- configured `description` or the first line of the server's instructions, or just
59
- `MCP server` without either.
63
+ configured `description` or the first line of the server's instructions
64
+ (without Markdown heading markers such as `# `), or just `MCP server`
65
+ without either.
60
66
 
61
- ## Sharing `@` with file paths
67
+ ## Completion popup
62
68
 
63
- Pi's native `@` completion lists files. Both appear in one popup, built here
64
- over whatever provider is already installed:
69
+ `&` has its own popup, like `$skill` and `#123` in the sibling selectors:
65
70
 
66
- - `@` alone lists every server by name, then the native files.
67
- - `@git` lists servers whose name contains `git` (case-insensitive, names that
68
- start with it first), then the native files.
69
- - The popup only merges for an unquoted `@name` in prose while at least one server
70
- is in the catalogue; otherwise native completion runs untouched. Servers are
71
- listed only when they match the query, but a file named like a server is always
72
- inserted as `@./name`, even for a fuzzy query such as `@ghb` that matches no
73
- server, so selecting a file never produces a server link.
71
+ - `&` after a space or other token boundary, or alone at the start of a line, lists
72
+ every server by name.
73
+ - `&git` lists servers whose name contains `git` (case-insensitive, names that
74
+ start with it first).
75
+ - The popup appears only for an unquoted `&name` in prose, and only when a server
76
+ matches. `&` is plain text in Markdown, so a lone `&` at the start of a line
77
+ opens the popup too. While you type an HTML entity such as `&amp;` the popup
78
+ may list matching servers, and it closes at `;`; the entity never converts.
74
79
 
75
- If file search fails or finds nothing, the servers are still listed. Pi does not
76
- expand `@path` in submitted messages, so only the popup and the literal text are
77
- shared. See [ADR 0001](docs/adr/0001-share-at-with-file-mentions.md) for why `@`.
80
+ Pi's editor opens the popup by itself only for `@`, `#`, and the characters the
81
+ outermost autocomplete provider lists in `triggerCharacters`. If another extension
82
+ wraps the provider without forwarding `triggerCharacters` (`@ff-labs/pi-fff` 0.11.0
83
+ does not), press Tab after `&` to open it. See
84
+ [ADR 0002](docs/adr/0002-reference-servers-with-ampersand.md) for why `&` and not `@`, `+`, or `$`.
78
85
 
79
86
  ## Integration
80
87
 
81
- Completion stacks over the current provider, retaining slash and file completion,
88
+ Completion stacks over the current provider, retaining slash and `@` file completion,
82
89
  keyboard controls, and custom editors. It runs in the interactive terminal only.
83
90
  Conversion applies to interactive, RPC, and print input, including steering and
84
91
  follow-ups, without changing attachments or message routing. Extension-generated
@@ -87,14 +94,14 @@ too.
87
94
 
88
95
  ## Troubleshooting
89
96
 
90
- - **No server in the popup:** open `/mcp` and check the server is enabled and
97
+ - **No server in the popup:** type `&` after a space, then open `/mcp` and check the server is enabled and
91
98
  connected, and has a tool that is not hidden. A server that needs sign-in or is
92
99
  still connecting is not listed.
93
100
  - **No link:** check the exact case-sensitive name (`-` and `_` match either way),
94
101
  and whether the reference is escaped, in code, inside a link, or followed by `/`,
95
- `.name`, or `:text`.
96
- - **File popup missing:** a quoted `@"…` or a path such as `@src/` uses Pi's native
97
- completion by design.
102
+ `.name`, `:text`, or `;` (an HTML entity).
103
+ - **`&` opens nothing:** press Tab after `&` (see Completion popup), and check the
104
+ server is in the Catalogue.
98
105
  - **Pi older than 1.2.0:** servers are named after their namespace (`dev_radius`),
99
106
  not the configured name; `-` and `_` still match either way.
100
107
  - **The model did not use the server:** inspect the submitted user message first.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-mcp-selector",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "Native MCP Server Reference autocomplete and link conversion for Pi user input.",
6
6
  "keywords": [
@@ -1,14 +1,14 @@
1
1
  ---
2
2
  name: pi-mcp-selector
3
- description: Diagnose Pi MCP Selector when `@server` autocomplete is missing or an MCP Server Reference does not convert to a link.
3
+ description: Diagnose Pi MCP Selector when `&server` autocomplete is missing or an MCP Server Reference does not convert to a link.
4
4
  license: MIT
5
5
  disable-model-invocation: true
6
6
  ---
7
7
 
8
8
  # Pi MCP Selector
9
9
 
10
- 1. Read [`../../README.md`](../../README.md) for the reference syntax, the MCP Server Catalogue rules, and how `@` is shared with file completion.
10
+ 1. Read [`../../README.md`](../../README.md) for the reference syntax, the MCP Server Catalogue rules, and the completion popup.
11
11
  2. For a missing server in the popup or a reference that stays literal, check the Catalogue first: `/mcp` must show the server enabled and connected with at least one tool that is not hidden. A server that is connecting, needs sign-in, or is disabled is absent by design. Finish when the server appears in the popup or this named state explains its absence.
12
- 3. For a missing popup, confirm extension loading and interactive mode, then try bare `@` at a prose token boundary. Quoted `@"…` and path prefixes use Pi's native file completion by design.
13
- 4. For a literal reference with the server present, compare the typed text with the submitted user message and check exact spelling and literal contexts: case, `@name/`, `@name.ext`, `@name:text`, escapes, code, and links. Finish when the link targets the configured server name or a named boundary explains the mismatch.
12
+ 3. For a missing popup, confirm extension loading and interactive mode, then try `&` after a space, then `&gi`. If another extension wraps the autocomplete provider without forwarding `triggerCharacters` (pi-fff 0.11.0 does not), press Tab after `&` to open the popup. `@` is Pi's file completion and never lists servers.
13
+ 4. For a literal reference with the server present, compare the typed text with the submitted user message and check exact spelling and literal contexts: case, `&name/`, `&name.ext`, `&name:text`, `&name;` (an HTML entity), `R&D`, `a && b`, escapes, code, and links. Finish when the link targets the configured server name or a named boundary explains the mismatch.
14
14
  5. If the link is correct but the model did not use the server, report conversion as working: the model controls tool use. Keep this extension configuration-free.
@@ -10,7 +10,7 @@ export interface McpServer {
10
10
  description?: string;
11
11
  }
12
12
 
13
- export const MCP_SERVER_SIGIL = "@";
13
+ export const MCP_SERVER_SIGIL = "&";
14
14
 
15
15
  /** Characters a partially typed server name may hold; Pi allows letters, digits, `_`, and `-`. */
16
16
  export const MCP_SERVER_PARTIAL_NAME = "[A-Za-z0-9_-]*";
@@ -21,12 +21,20 @@ const SERVER_NAME = /^[A-Za-z0-9_-]+$/u;
21
21
  type CatalogueTool = Pick<ToolInfo, "exposure" | "namespace">;
22
22
 
23
23
  /** Hyphen and underscore name the same server, since Pi's namespace folds them together. */
24
- export const nameKey = (name: string): string => name.replaceAll("-", "_");
24
+ const nameKey = (name: string): string => name.replaceAll("-", "_");
25
25
 
26
+ /** An ATX heading line; group 1 is its text without the opening or closing `#`s. */
27
+ const ATX_HEADING = /^#{1,6}(?:\s+(.*?))?(?:\s+#+)?$/u;
28
+
29
+ /** The first non-empty line, without Markdown ATX heading markers (`# Executor`, `## Executor ##`). */
26
30
  const firstLine = (text: string | undefined): string | undefined =>
27
31
  text
28
32
  ?.split("\n")
29
- .map((line) => line.trim())
33
+ .map((line) => {
34
+ const trimmed = line.trim();
35
+ const heading = ATX_HEADING.exec(trimmed);
36
+ return heading === null ? trimmed : (heading[1] ?? "").trim();
37
+ })
30
38
  .find((line) => line !== "");
31
39
 
32
40
  /**
@@ -75,9 +83,9 @@ export function matchMcpServers(servers: readonly McpServer[], query: string): M
75
83
  const escapeRegex = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
76
84
 
77
85
  /**
78
- * Converts `@server` shorthand for a catalogue server into `[@server](mcp://server "MCP server
86
+ * Converts `&server` shorthand for a catalogue server into `[&server](mcp://server "MCP server
79
87
  * mcp__server")` inside Prose Spans. Unknown names, code, links, and names that run into a path,
80
- * domain, or `name:text` stay literal.
88
+ * domain, `name:text`, or `;` (an HTML entity such as `&amp;`) stay literal.
81
89
  */
82
90
  export function transformMcpServerReferences(text: string, servers: readonly McpServer[]): string {
83
91
  const catalogue = new Map(servers.map((server) => [nameKey(server.name), server]));
@@ -88,8 +96,9 @@ export function transformMcpServerReferences(text: string, servers: readonly Mcp
88
96
  if (names.length === 0) return text;
89
97
  return replaceProseReferences(text, {
90
98
  sigil: MCP_SERVER_SIGIL,
91
- // A colon opens a reference only before a space (`@server: list`); `@server:resource` is a URI.
92
- name: `(?:${names.join("|")})(?!:\\S)`,
99
+ // A colon opens a reference only before a space (`&server: list`); `&server:resource` is a URI.
100
+ // A semicolon right after the name would make an HTML entity (`&amp;`), which stays literal.
101
+ name: `(?:${names.join("|")})(?!:\\S|;)`,
93
102
  resolve: (typed) => {
94
103
  const server = catalogue.get(nameKey(typed));
95
104
  if (server === undefined) return undefined;
@@ -1,14 +1,39 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { stackMcpServerAutocomplete } from "./mcp-server-autocomplete.js";
3
- import { getMcpServerCatalogue, transformMcpServerReferences } from "./mcp-server-references.js";
2
+ import type { AutocompleteProvider } from "@earendil-works/pi-tui";
3
+ import { stackProseReferenceAutocomplete } from "@ian-pascoe/pi-utils/prose-references";
4
+ import {
5
+ getMcpServerCatalogue,
6
+ MCP_SERVER_PARTIAL_NAME,
7
+ MCP_SERVER_SIGIL,
8
+ matchMcpServers,
9
+ transformMcpServerReferences,
10
+ } from "./mcp-server-references.js";
4
11
 
5
12
  export default function mcpSelectorExtension(pi: ExtensionAPI): void {
6
13
  // Read fresh on every use: servers connect, disconnect, and change exposure during a session.
7
14
  const getCatalogue = () => getMcpServerCatalogue(pi.getAllTools());
8
15
 
16
+ const createAutocompleteProvider = (current: AutocompleteProvider): AutocompleteProvider =>
17
+ stackProseReferenceAutocomplete(current, {
18
+ sigil: MCP_SERVER_SIGIL,
19
+ partialName: MCP_SERVER_PARTIAL_NAME,
20
+ suggest: (query) =>
21
+ matchMcpServers(getCatalogue(), query).map((server) => {
22
+ const value = `${MCP_SERVER_SIGIL}${server.name}`;
23
+ return {
24
+ value,
25
+ label: value,
26
+ description:
27
+ server.description === undefined
28
+ ? "MCP server"
29
+ : `MCP server · ${server.description}`,
30
+ };
31
+ }),
32
+ });
33
+
9
34
  pi.on("session_start", (_event, ctx) => {
10
35
  if (ctx.mode !== "tui") return;
11
- ctx.ui.addAutocompleteProvider((current) => stackMcpServerAutocomplete(current, getCatalogue));
36
+ ctx.ui.addAutocompleteProvider(createAutocompleteProvider);
12
37
  });
13
38
 
14
39
  pi.on("input", (event) => {
@@ -1,104 +0,0 @@
1
- import type { AutocompleteItem, AutocompleteProvider } from "@earendil-works/pi-tui";
2
- import { findProseReferencePrefix } from "@ian-pascoe/pi-utils/prose-references";
3
- import {
4
- MCP_SERVER_PARTIAL_NAME,
5
- MCP_SERVER_SIGIL,
6
- matchMcpServers,
7
- nameKey,
8
- type McpServer,
9
- } from "./mcp-server-references.js";
10
-
11
- const grammar = { sigil: MCP_SERVER_SIGIL, partialName: MCP_SERVER_PARTIAL_NAME };
12
-
13
- function prefixAt(lines: string[], cursorLine: number, cursorCol: number): string | null {
14
- const cursorOffset = lines
15
- .slice(0, cursorLine)
16
- .reduce((offset, line) => offset + line.length + 1, cursorCol);
17
- return findProseReferencePrefix(lines.join("\n"), cursorOffset, grammar);
18
- }
19
-
20
- function serverItem(server: McpServer): AutocompleteItem {
21
- const value = `${MCP_SERVER_SIGIL}${server.name}`;
22
- const description =
23
- server.description === undefined ? "MCP server" : `MCP server · ${server.description}`;
24
- return { value, label: value, description };
25
- }
26
-
27
- /**
28
- * Pi's native `@` completion lists files, so a file named like a server would insert text that
29
- * converts to a server link. Insert such a file as `@./name` instead.
30
- */
31
- function withoutServerCollision(
32
- item: AutocompleteItem,
33
- servers: readonly McpServer[],
34
- ): AutocompleteItem {
35
- if (!item.value.startsWith(MCP_SERVER_SIGIL)) return item;
36
- const name = item.value.slice(MCP_SERVER_SIGIL.length);
37
- const key = nameKey(name);
38
- if (!servers.some((server) => nameKey(server.name) === key)) return item;
39
- return { ...item, value: `${MCP_SERVER_SIGIL}./${name}` };
40
- }
41
-
42
- /**
43
- * Lists MCP servers above the files Pi's native `@` completion finds, in one popup. The
44
- * returned provider engages only for an unquoted `@name` prefix in prose while the Catalogue is
45
- * not empty, and delegates everything else, including applying native items, to `current`. Servers
46
- * are listed only when they match; native files that collide with a server name are rewritten
47
- * either way.
48
- */
49
- export function stackMcpServerAutocomplete(
50
- current: AutocompleteProvider,
51
- getCatalogue: () => readonly McpServer[],
52
- ): AutocompleteProvider {
53
- // The editor hands an item back by identity, which is how a server item is told from a file.
54
- const ownItems = new WeakSet<AutocompleteItem>();
55
- return {
56
- triggerCharacters: [...new Set([...(current.triggerCharacters ?? []), MCP_SERVER_SIGIL])],
57
- async getSuggestions(lines, cursorLine, cursorCol, requestOptions) {
58
- const delegate = () => current.getSuggestions(lines, cursorLine, cursorCol, requestOptions);
59
- const prefix = prefixAt(lines, cursorLine, cursorCol);
60
- if (prefix === null) return delegate();
61
- const catalogue = getCatalogue();
62
- if (catalogue.length === 0) return delegate();
63
- const matches = matchMcpServers(catalogue, prefix.slice(MCP_SERVER_SIGIL.length));
64
- if (requestOptions.signal.aborted) return null;
65
- const items = matches.map(serverItem);
66
- for (const item of items) ownItems.add(item);
67
- let native: Awaited<ReturnType<AutocompleteProvider["getSuggestions"]>> = null;
68
- try {
69
- native = await delegate();
70
- } catch {
71
- // Servers still help when file search fails.
72
- }
73
- if (requestOptions.signal.aborted) return null;
74
- if (native === null || native.items.length === 0) {
75
- return items.length === 0 ? native : { prefix, items };
76
- }
77
- if (native.prefix !== prefix) return native;
78
- // Native search is fuzzy, so a file named like a server can appear for a query that matches
79
- // no server (`@ghb` finds `github`); it must still insert as `@./name`.
80
- const files = native.items.map((item) => withoutServerCollision(item, catalogue));
81
- if (items.length === 0 && files.every((file, index) => file === native.items[index])) {
82
- return native;
83
- }
84
- return { prefix, items: [...items, ...files] };
85
- },
86
- applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
87
- if (!ownItems.has(item)) {
88
- return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
89
- }
90
- const line = lines[cursorLine] ?? "";
91
- const beforePrefix = line.slice(0, cursorCol - prefix.length);
92
- const completed = [...lines];
93
- completed[cursorLine] = `${beforePrefix}${item.value} ${line.slice(cursorCol)}`;
94
- return {
95
- lines: completed,
96
- cursorLine,
97
- cursorCol: beforePrefix.length + item.value.length + 1,
98
- };
99
- },
100
- shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
101
- return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
102
- },
103
- };
104
- }