@ian-pascoe/pi-mcp-selector 0.0.0-stage → 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright 2026 Ian Pascoe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,101 @@
1
- # Temporary Holding Version
1
+ # Pi MCP Selector
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
7
+
8
+ ```bash
9
+ pi install npm:@ian-pascoe/pi-mcp-selector
10
+ ```
11
+
12
+ Requires Pi `>=1.1.0` and Node `>=22.19.0`. There are no settings or extra tools.
13
+ Prose Span detection comes from
14
+ [`@ian-pascoe/pi-utils/prose-references`](../pi-utils/README.md#prose-references).
15
+
16
+ ## Submission
17
+
18
+ Typed, pasted, and selected references are equivalent. On submission:
19
+
20
+ ```text
21
+ Check open issues with @github.
22
+ → Check open issues with [@github](mcp://github "MCP server mcp__github").
23
+ ```
24
+
25
+ The link target is the configured server name and its title is the namespace
26
+ Pi gives the server's tools. The model receives a link, not instructions or tool
27
+ definitions, and chooses what to do with it. A reference only points: it does
28
+ not connect, load, or expose anything, and it leaves tool exposure, the system
29
+ prompt, and tool declarations unchanged. The submitted transcript stores the link
30
+ rather than a separate raw shorthand message.
31
+
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
34
+ `dev-radius`). These stay literal:
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)
39
+ - code, existing Markdown links, and HTML
40
+
41
+ Sentence punctuation does not block a reference: `@github's`, `(@github)`,
42
+ `@github.`, and `@github: list issues` all convert. Reuse of existing links
43
+ is safe, so submitting converted text again changes nothing.
44
+
45
+ ## The MCP Server Catalogue
46
+
47
+ 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
49
+ has at least one tool that is not `hidden`, whether it comes from `mcp.json` or an
50
+ extension's `registerMcpServer()`. Its exposure (`codemode`, `deferred`, `direct`)
51
+ does not matter, and neither does whether `codemode` is active.
52
+
53
+ - A disabled server re-registers its tools as hidden, so it is absent.
54
+ - 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
56
+ reference usually stays literal while servers connect.
57
+ - 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.
60
+
61
+ ## Sharing `@` with file paths
62
+
63
+ Pi's native `@` completion lists files. Both appear in one popup, built here
64
+ over whatever provider is already installed:
65
+
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.
74
+
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 `@`.
78
+
79
+ ## Integration
80
+
81
+ Completion stacks over the current provider, retaining slash and file completion,
82
+ keyboard controls, and custom editors. It runs in the interactive terminal only.
83
+ Conversion applies to interactive, RPC, and print input, including steering and
84
+ follow-ups, without changing attachments or message routing. Extension-generated
85
+ input is unchanged. Direct RPC `steer` and `follow_up` commands run Pi's input hooks
86
+ too.
87
+
88
+ ## Troubleshooting
89
+
90
+ - **No server in the popup:** open `/mcp` and check the server is enabled and
91
+ connected, and has a tool that is not hidden. A server that needs sign-in or is
92
+ still connecting is not listed.
93
+ - **No link:** check the exact case-sensitive name (`-` and `_` match either way),
94
+ 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.
98
+ - **Pi older than 1.2.0:** servers are named after their namespace (`dev_radius`),
99
+ not the configured name; `-` and `_` still match either way.
100
+ - **The model did not use the server:** inspect the submitted user message first.
101
+ A correct link verifies conversion; using the server is the model's choice.
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@ian-pascoe/pi-mcp-selector",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.0",
4
+ "private": false,
5
+ "description": "Native MCP Server Reference autocomplete and link conversion for Pi user input.",
6
+ "keywords": [
7
+ "autocomplete",
8
+ "mcp",
9
+ "pi",
10
+ "pi-extension",
11
+ "pi-package"
12
+ ],
13
+ "homepage": "https://github.com/ian-pascoe/pi-extensions/tree/main/packages/pi-mcp-selector",
14
+ "bugs": {
15
+ "url": "https://github.com/ian-pascoe/pi-extensions/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Ian Pascoe <ian.g.pascoe@gmail.com>",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/ian-pascoe/pi-extensions.git",
22
+ "directory": "packages/pi-mcp-selector"
23
+ },
24
+ "files": [
25
+ "src",
26
+ "skills",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "type": "module",
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "provenance": true
34
+ },
35
+ "scripts": {
36
+ "test": "vitest run",
37
+ "typecheck": "tsrs --noEmit -p tsconfig.json"
38
+ },
39
+ "dependencies": {
40
+ "@ian-pascoe/pi-utils": "^0.11.0"
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-coding-agent": ">=1.1.0",
44
+ "@earendil-works/pi-tui": ">=1.1.0"
45
+ },
46
+ "engines": {
47
+ "node": ">=22.19.0"
48
+ },
49
+ "pi": {
50
+ "extensions": [
51
+ "./src/index.ts"
52
+ ],
53
+ "skills": [
54
+ "./skills"
55
+ ]
56
+ }
57
+ }
@@ -0,0 +1,14 @@
1
+ ---
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.
4
+ license: MIT
5
+ disable-model-invocation: true
6
+ ---
7
+
8
+ # Pi MCP Selector
9
+
10
+ 1. Read [`../../README.md`](../../README.md) for the reference syntax, the MCP Server Catalogue rules, and how `@` is shared with file completion.
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.
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.
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./pi-mcp-selector-extension.js";
@@ -0,0 +1,104 @@
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
+ }
@@ -0,0 +1,99 @@
1
+ import type { ToolInfo } from "@earendil-works/pi-coding-agent";
2
+ import { replaceProseReferences } from "@ian-pascoe/pi-utils/prose-references";
3
+
4
+ /** An MCP server the session can reach, as the model's namespace for its tools names it. */
5
+ export interface McpServer {
6
+ /** The configured server name; the target of its `mcp://` links and what users type. */
7
+ name: string;
8
+ /** Pi's tool namespace for the server, such as `mcp__github`. */
9
+ namespace: string;
10
+ description?: string;
11
+ }
12
+
13
+ export const MCP_SERVER_SIGIL = "@";
14
+
15
+ /** Characters a partially typed server name may hold; Pi allows letters, digits, `_`, and `-`. */
16
+ export const MCP_SERVER_PARTIAL_NAME = "[A-Za-z0-9_-]*";
17
+
18
+ const NAMESPACE_PREFIX = "mcp__";
19
+ const SERVER_NAME = /^[A-Za-z0-9_-]+$/u;
20
+
21
+ type CatalogueTool = Pick<ToolInfo, "exposure" | "namespace">;
22
+
23
+ /** Hyphen and underscore name the same server, since Pi's namespace folds them together. */
24
+ export const nameKey = (name: string): string => name.replaceAll("-", "_");
25
+
26
+ const firstLine = (text: string | undefined): string | undefined =>
27
+ text
28
+ ?.split("\n")
29
+ .map((line) => line.trim())
30
+ .find((line) => line !== "");
31
+
32
+ /**
33
+ * The MCP Server Catalogue: each `mcp__` namespace that has a tool Pi has not hidden, so disabled
34
+ * servers and servers without tools yet (connecting, or needing sign-in) are absent. A server is
35
+ * named by its first namespace alias, which Pi sets to the configured name, else by the
36
+ * namespace without its prefix. Sorted by name.
37
+ */
38
+ export function getMcpServerCatalogue(tools: readonly CatalogueTool[]): McpServer[] {
39
+ const servers = new Map<string, McpServer>();
40
+ for (const { exposure, namespace } of tools) {
41
+ if (namespace === undefined || exposure === "hidden" || servers.has(namespace.name)) continue;
42
+ if (!namespace.name.startsWith(NAMESPACE_PREFIX)) continue;
43
+ const alias = namespace.aliases?.[0];
44
+ const name =
45
+ alias !== undefined && SERVER_NAME.test(alias)
46
+ ? alias
47
+ : namespace.name.slice(NAMESPACE_PREFIX.length);
48
+ if (!SERVER_NAME.test(name)) continue;
49
+ const server: McpServer = { name, namespace: namespace.name };
50
+ const description = firstLine(namespace.description) ?? firstLine(namespace.instructions);
51
+ if (description !== undefined) server.description = description;
52
+ servers.set(namespace.name, server);
53
+ }
54
+ return [...servers.values()].sort((left, right) => left.name.localeCompare(right.name, "en"));
55
+ }
56
+
57
+ /**
58
+ * The servers whose name contains `query` (case-insensitive), names that start with it first, each
59
+ * group sorted by name. An empty query lists every server.
60
+ */
61
+ export function matchMcpServers(servers: readonly McpServer[], query: string): McpServer[] {
62
+ const wanted = nameKey(query).toLowerCase();
63
+ const matches = servers
64
+ .map((server) => ({ server, index: nameKey(server.name).toLowerCase().indexOf(wanted) }))
65
+ .filter(({ index }) => index >= 0);
66
+ const rank = ({ index }: { index: number }): number => (index === 0 ? 0 : 1);
67
+ return matches
68
+ .sort(
69
+ (left, right) =>
70
+ rank(left) - rank(right) || left.server.name.localeCompare(right.server.name, "en"),
71
+ )
72
+ .map(({ server }) => server);
73
+ }
74
+
75
+ const escapeRegex = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
76
+
77
+ /**
78
+ * Converts `@server` shorthand for a catalogue server into `[@server](mcp://server "MCP server
79
+ * mcp__server")` inside Prose Spans. Unknown names, code, links, and names that run into a path,
80
+ * domain, or `name:text` stay literal.
81
+ */
82
+ export function transformMcpServerReferences(text: string, servers: readonly McpServer[]): string {
83
+ const catalogue = new Map(servers.map((server) => [nameKey(server.name), server]));
84
+ const names = [...catalogue.keys()]
85
+ .sort((left, right) => right.length - left.length)
86
+ // The catalogue key folds `-` into `_`; a typed name may use either.
87
+ .map((key) => escapeRegex(key).replaceAll("_", "[-_]"));
88
+ if (names.length === 0) return text;
89
+ return replaceProseReferences(text, {
90
+ 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)`,
93
+ resolve: (typed) => {
94
+ const server = catalogue.get(nameKey(typed));
95
+ if (server === undefined) return undefined;
96
+ return `[${MCP_SERVER_SIGIL}${typed}](mcp://${server.name} "MCP server ${server.namespace}")`;
97
+ },
98
+ });
99
+ }
@@ -0,0 +1,22 @@
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";
4
+
5
+ export default function mcpSelectorExtension(pi: ExtensionAPI): void {
6
+ // Read fresh on every use: servers connect, disconnect, and change exposure during a session.
7
+ const getCatalogue = () => getMcpServerCatalogue(pi.getAllTools());
8
+
9
+ pi.on("session_start", (_event, ctx) => {
10
+ if (ctx.mode !== "tui") return;
11
+ ctx.ui.addAutocompleteProvider((current) => stackMcpServerAutocomplete(current, getCatalogue));
12
+ });
13
+
14
+ pi.on("input", (event) => {
15
+ if (event.source === "extension") return { action: "continue" };
16
+ const text = transformMcpServerReferences(event.text, getCatalogue());
17
+ if (text === event.text) return { action: "continue" };
18
+ return event.images === undefined
19
+ ? { action: "transform", text }
20
+ : { action: "transform", text, images: event.images };
21
+ });
22
+ }