@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 +21 -0
- package/README.md +100 -2
- package/package.json +55 -4
- package/skills/pi-mcp-selector/SKILL.md +14 -0
- package/src/index.ts +1 -0
- package/src/mcp-server-autocomplete.ts +104 -0
- package/src/mcp-server-references.ts +99 -0
- package/src/pi-mcp-selector-extension.ts +22 -0
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
|
-
#
|
|
1
|
+
# Pi MCP Selector
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
}
|