@pikku/inspector 0.12.73 → 0.12.75
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/CHANGELOG.md +28 -0
- package/dist/add/add-functions.js +15 -3
- package/dist/utils/filter-inspector-state.js +9 -13
- package/package.json +3 -3
- package/src/add/add-functions.test.ts +98 -0
- package/src/add/add-functions.ts +16 -3
- package/src/utils/filter-inspector-state.test.ts +41 -16
- package/src/utils/filter-inspector-state.ts +9 -13
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
## 0.12.75
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- b28a8c0: An addon's exposed functions get their own deployment unit
|
|
6
|
+
|
|
7
|
+
`collectFilterNames` adds the `/rpc/:rpcName` scaffold name to every unit holding an exposed function, so that unit can serve its own `/rpc/<funcName>`. `filterInspectorState` read that same string back as "this unit is the generic dispatcher" and kept every wired addon's declaration for it, so almost every function unit imported each addon's whole package bootstrap — and the node-only modules it reaches.
|
|
8
|
+
|
|
9
|
+
No unit retains a wired addon on the strength of a catch-all route any more. Instead the analyzer emits a unit per addon carrying that addon's exposed functions, and the unit serving `/rpc/:rpcName` dispatches to it over a service binding rather than bundling it. Granularity is per addon rather than per function because an addon publishes a single prebuilt bootstrap that registers all of its functions at once.
|
|
10
|
+
|
|
11
|
+
`node:child_process` joins `node:fs` as a stubbed builtin: Workers have no implementation of it under any compatibility flag, and it is reached from the console addon's audit tooling.
|
|
12
|
+
|
|
13
|
+
`DeploymentUnit` gains `dispatch`, an RPC-name → unit-name map for calls whose target unit isn't the kebab-cased RPC name. `rpc.exposed(name)` falls through to the deployment service for a namespaced name with no local metadata, and Cloudflare function units with a `dependsOn` now get a `deploymentService`, which previously only gateway units had.
|
|
14
|
+
|
|
15
|
+
- Updated dependencies [b28a8c0]
|
|
16
|
+
- @pikku/core@0.12.107
|
|
17
|
+
|
|
18
|
+
## 0.12.74
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- 1becc2d: Publish an MCP tool under its declared `name`
|
|
23
|
+
|
|
24
|
+
`pikkuMCPToolFunc`'s config accepts a `name`, and it is the name an AI client
|
|
25
|
+
calls the tool by, but the inspector never read it — the tool was always
|
|
26
|
+
published under whatever the export happened to be called. The declared name
|
|
27
|
+
now keys `toolsMeta`; `pikkuFuncId` is unchanged.
|
|
28
|
+
|
|
1
29
|
## 0.12.73
|
|
2
30
|
|
|
3
31
|
### Patch Changes
|
|
@@ -1053,13 +1053,25 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
1053
1053
|
};
|
|
1054
1054
|
}
|
|
1055
1055
|
if (mcpEnabled) {
|
|
1056
|
+
// `pikkuMCPToolFunc`'s config accepts `name`, and it is the name an AI client
|
|
1057
|
+
// calls the tool by — `runMCPTool` looks the tool up in `toolsMeta` by exactly
|
|
1058
|
+
// this key and then dispatches through `pikkuFuncId`, so it is a public label
|
|
1059
|
+
// rather than an identity. It was never read here, so a declared `name` was
|
|
1060
|
+
// silently dropped and the tool published under whatever the export happened to
|
|
1061
|
+
// be called. It does NOT become the `pikkuFuncId`: renaming an export's rpc id
|
|
1062
|
+
// is a different decision, and the scenario-step wrappers are the only place
|
|
1063
|
+
// `name` carries that weight.
|
|
1064
|
+
const declaredName = objectNode
|
|
1065
|
+
? getPropertyValue(objectNode, 'name')
|
|
1066
|
+
: undefined;
|
|
1067
|
+
const toolName = typeof declaredName === 'string' && declaredName ? declaredName : name;
|
|
1056
1068
|
if (!description) {
|
|
1057
|
-
logger.warn(`MCP tool '${
|
|
1069
|
+
logger.warn(`MCP tool '${toolName}' is missing a description.`);
|
|
1058
1070
|
}
|
|
1059
1071
|
state.mcpEndpoints.files.add(node.getSourceFile().fileName);
|
|
1060
|
-
state.mcpEndpoints.toolsMeta[
|
|
1072
|
+
state.mcpEndpoints.toolsMeta[toolName] = {
|
|
1061
1073
|
pikkuFuncId,
|
|
1062
|
-
name,
|
|
1074
|
+
name: toolName,
|
|
1063
1075
|
title: title || undefined,
|
|
1064
1076
|
description: description || undefined,
|
|
1065
1077
|
summary,
|
|
@@ -914,21 +914,17 @@ export function filterInspectorState(state, filters, logger) {
|
|
|
914
914
|
for (const tool of agentMeta.tools ?? [])
|
|
915
915
|
referencedIds.add(tool);
|
|
916
916
|
}
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
917
|
+
// An addon RPC named outright in the filter is what a unit built FOR that
|
|
918
|
+
// addon is given, so its declaration has to survive even though no
|
|
919
|
+
// app-local function references it.
|
|
920
|
+
for (const name of filters.names ?? []) {
|
|
921
|
+
const namespace = name.slice(0, name.indexOf(':'));
|
|
922
|
+
if (namespace && filteredState.rpc.wireAddonDeclarations.has(namespace)) {
|
|
923
|
+
referencedIds.add(name);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
926
|
const keptNamespaces = new Set();
|
|
927
927
|
for (const namespace of filteredState.rpc.wireAddonDeclarations.keys()) {
|
|
928
|
-
if (hasRpcCatchAll) {
|
|
929
|
-
keptNamespaces.add(namespace);
|
|
930
|
-
continue;
|
|
931
|
-
}
|
|
932
928
|
const prefix = `${namespace}:`;
|
|
933
929
|
for (const id of referencedIds) {
|
|
934
930
|
if (id.startsWith(prefix)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.75",
|
|
4
4
|
"description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "BUSL-1.1",
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
"zod-to-ts": "^2.1.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@pikku/core": "^0.12.
|
|
48
|
+
"@pikku/core": "^0.12.107"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.107",
|
|
52
52
|
"@types/node": "^24.13.3"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
|
@@ -643,3 +643,101 @@ describe('pikkuRemoteChannelFunc', () => {
|
|
|
643
643
|
)
|
|
644
644
|
})
|
|
645
645
|
})
|
|
646
|
+
|
|
647
|
+
describe('MCP tool naming', () => {
|
|
648
|
+
const quietLogger = (): InspectorLogger => ({
|
|
649
|
+
debug: () => {},
|
|
650
|
+
info: () => {},
|
|
651
|
+
warn: () => {},
|
|
652
|
+
error: () => {},
|
|
653
|
+
diagnostic: () => {},
|
|
654
|
+
critical: () => {},
|
|
655
|
+
hasCriticalErrors: () => false,
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
test('a declared name is what the tool is published as', async () => {
|
|
659
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-mcp-tool-name-'))
|
|
660
|
+
const file = join(rootDir, 'tool.ts')
|
|
661
|
+
await writeFile(
|
|
662
|
+
file,
|
|
663
|
+
[
|
|
664
|
+
"import { pikkuMCPToolFunc } from '@pikku/core'",
|
|
665
|
+
'export const createUnansweredCountTool = pikkuMCPToolFunc({',
|
|
666
|
+
" name: 'getUnansweredConversationCount',",
|
|
667
|
+
" description: 'How many conversations are unanswered.',",
|
|
668
|
+
' func: async () => ({ ok: true })',
|
|
669
|
+
'})',
|
|
670
|
+
].join('\n')
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
try {
|
|
674
|
+
const state = await inspect(quietLogger(), [file], { rootDir })
|
|
675
|
+
const tools = state.mcpEndpoints.toolsMeta
|
|
676
|
+
assert.ok(
|
|
677
|
+
tools['getUnansweredConversationCount'],
|
|
678
|
+
`expected the declared name, got: ${Object.keys(tools).join(', ')}`
|
|
679
|
+
)
|
|
680
|
+
assert.strictEqual(tools['createUnansweredCountTool'], undefined)
|
|
681
|
+
assert.strictEqual(
|
|
682
|
+
tools['getUnansweredConversationCount']!.pikkuFuncId,
|
|
683
|
+
'createUnansweredCountTool'
|
|
684
|
+
)
|
|
685
|
+
} finally {
|
|
686
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
687
|
+
}
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
test('with no declared name the export still names the tool', async () => {
|
|
691
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-mcp-tool-export-'))
|
|
692
|
+
const file = join(rootDir, 'tool.ts')
|
|
693
|
+
await writeFile(
|
|
694
|
+
file,
|
|
695
|
+
[
|
|
696
|
+
"import { pikkuMCPToolFunc } from '@pikku/core'",
|
|
697
|
+
'export const listOpenTickets = pikkuMCPToolFunc({',
|
|
698
|
+
" description: 'Tickets still open.',",
|
|
699
|
+
' func: async () => ({ ok: true })',
|
|
700
|
+
'})',
|
|
701
|
+
].join('\n')
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
try {
|
|
705
|
+
const state = await inspect(quietLogger(), [file], { rootDir })
|
|
706
|
+
assert.ok(state.mcpEndpoints.toolsMeta['listOpenTickets'])
|
|
707
|
+
} finally {
|
|
708
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
709
|
+
}
|
|
710
|
+
})
|
|
711
|
+
test('a plain func with mcp: true is published under its declared name', async () => {
|
|
712
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-mcp-flag-name-'))
|
|
713
|
+
const file = join(rootDir, 'tool.ts')
|
|
714
|
+
await writeFile(
|
|
715
|
+
file,
|
|
716
|
+
[
|
|
717
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
718
|
+
'export const countUnanswered = pikkuFunc({',
|
|
719
|
+
' mcp: true,',
|
|
720
|
+
" name: 'getUnansweredConversationCount',",
|
|
721
|
+
" description: 'How many conversations are unanswered.',",
|
|
722
|
+
' func: async () => ({ ok: true })',
|
|
723
|
+
'})',
|
|
724
|
+
].join('\n')
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
const state = await inspect(quietLogger(), [file], { rootDir })
|
|
729
|
+
const tools = state.mcpEndpoints.toolsMeta
|
|
730
|
+
assert.ok(
|
|
731
|
+
tools['getUnansweredConversationCount'],
|
|
732
|
+
`expected the declared name, got: ${Object.keys(tools).join(', ')}`
|
|
733
|
+
)
|
|
734
|
+
assert.strictEqual(tools['countUnanswered'], undefined)
|
|
735
|
+
assert.strictEqual(
|
|
736
|
+
tools['getUnansweredConversationCount']!.pikkuFuncId,
|
|
737
|
+
'countUnanswered'
|
|
738
|
+
)
|
|
739
|
+
} finally {
|
|
740
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
741
|
+
}
|
|
742
|
+
})
|
|
743
|
+
})
|
package/src/add/add-functions.ts
CHANGED
|
@@ -1431,13 +1431,26 @@ export const addFunctions: AddWiring = (
|
|
|
1431
1431
|
}
|
|
1432
1432
|
|
|
1433
1433
|
if (mcpEnabled) {
|
|
1434
|
+
// `pikkuMCPToolFunc`'s config accepts `name`, and it is the name an AI client
|
|
1435
|
+
// calls the tool by — `runMCPTool` looks the tool up in `toolsMeta` by exactly
|
|
1436
|
+
// this key and then dispatches through `pikkuFuncId`, so it is a public label
|
|
1437
|
+
// rather than an identity. It was never read here, so a declared `name` was
|
|
1438
|
+
// silently dropped and the tool published under whatever the export happened to
|
|
1439
|
+
// be called. It does NOT become the `pikkuFuncId`: renaming an export's rpc id
|
|
1440
|
+
// is a different decision, and the scenario-step wrappers are the only place
|
|
1441
|
+
// `name` carries that weight.
|
|
1442
|
+
const declaredName = objectNode
|
|
1443
|
+
? getPropertyValue(objectNode, 'name')
|
|
1444
|
+
: undefined
|
|
1445
|
+
const toolName =
|
|
1446
|
+
typeof declaredName === 'string' && declaredName ? declaredName : name
|
|
1434
1447
|
if (!description) {
|
|
1435
|
-
logger.warn(`MCP tool '${
|
|
1448
|
+
logger.warn(`MCP tool '${toolName}' is missing a description.`)
|
|
1436
1449
|
}
|
|
1437
1450
|
state.mcpEndpoints.files.add(node.getSourceFile().fileName)
|
|
1438
|
-
state.mcpEndpoints.toolsMeta[
|
|
1451
|
+
state.mcpEndpoints.toolsMeta[toolName] = {
|
|
1439
1452
|
pikkuFuncId,
|
|
1440
|
-
name,
|
|
1453
|
+
name: toolName,
|
|
1441
1454
|
title: title || undefined,
|
|
1442
1455
|
description: description || undefined,
|
|
1443
1456
|
summary,
|
|
@@ -2143,14 +2143,6 @@ describe('invokedAgentsByFile', () => {
|
|
|
2143
2143
|
})
|
|
2144
2144
|
|
|
2145
2145
|
describe('Wired addon retention', () => {
|
|
2146
|
-
// A unit that owns the generic `/rpc/:rpcName` catch-all (the rpcCaller
|
|
2147
|
-
// unit, and agent units) dispatches arbitrary RPCs at runtime via
|
|
2148
|
-
// `rpc.exposed(name)`, including namespaced addon RPCs like
|
|
2149
|
-
// `admin:createUser`. No app-local function statically references those
|
|
2150
|
-
// ids, so the catch-all must be enough to keep every wired addon's
|
|
2151
|
-
// declaration — otherwise the per-unit bootstrap omits the addon package
|
|
2152
|
-
// bootstrap and `rpc.exposed('admin:createUser')` throws RPCNotFoundError
|
|
2153
|
-
// on a deployed stage.
|
|
2154
2146
|
const withAddons = () => {
|
|
2155
2147
|
const state = createMockInspectorState()
|
|
2156
2148
|
state.rpc.wireAddonDeclarations = new Map([
|
|
@@ -2161,20 +2153,53 @@ describe('invokedAgentsByFile', () => {
|
|
|
2161
2153
|
return state
|
|
2162
2154
|
}
|
|
2163
2155
|
|
|
2164
|
-
test('keeps
|
|
2156
|
+
test('keeps only the addon whose function the unit is built for', () => {
|
|
2165
2157
|
const result = filterInspectorState(
|
|
2166
2158
|
withAddons(),
|
|
2167
|
-
{
|
|
2159
|
+
{
|
|
2160
|
+
names: [
|
|
2161
|
+
'console:runSecurityAudit',
|
|
2162
|
+
'/rpc/:rpcName',
|
|
2163
|
+
'http:post:/rpc/:rpcName',
|
|
2164
|
+
'rpcCaller',
|
|
2165
|
+
],
|
|
2166
|
+
},
|
|
2168
2167
|
mockLogger
|
|
2169
2168
|
)
|
|
2170
2169
|
assert.deepStrictEqual(
|
|
2171
|
-
[...result.rpc.wireAddonDeclarations.keys()]
|
|
2172
|
-
['
|
|
2170
|
+
[...result.rpc.wireAddonDeclarations.keys()],
|
|
2171
|
+
['console']
|
|
2173
2172
|
)
|
|
2174
|
-
assert.deepStrictEqual([...result.rpc.usedAddons]
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2173
|
+
assert.deepStrictEqual([...result.rpc.usedAddons], ['console'])
|
|
2174
|
+
})
|
|
2175
|
+
|
|
2176
|
+
test('drops every wired addon for a unit serving the catch-all', () => {
|
|
2177
|
+
const result = filterInspectorState(
|
|
2178
|
+
withAddons(),
|
|
2179
|
+
{
|
|
2180
|
+
names: ['/rpc/:rpcName', 'http:post:/rpc/:rpcName', 'rpcCaller'],
|
|
2181
|
+
},
|
|
2182
|
+
mockLogger
|
|
2183
|
+
)
|
|
2184
|
+
assert.deepStrictEqual([...result.rpc.wireAddonDeclarations.keys()], [])
|
|
2185
|
+
assert.deepStrictEqual([...result.rpc.usedAddons], [])
|
|
2186
|
+
})
|
|
2187
|
+
|
|
2188
|
+
test('drops wired addons for a unit given the scaffold names', () => {
|
|
2189
|
+
const result = filterInspectorState(
|
|
2190
|
+
withAddons(),
|
|
2191
|
+
{
|
|
2192
|
+
names: [
|
|
2193
|
+
'addRetreatPerson',
|
|
2194
|
+
'/rpc/:rpcName',
|
|
2195
|
+
'http:post:/rpc/:rpcName',
|
|
2196
|
+
'rpcCaller',
|
|
2197
|
+
],
|
|
2198
|
+
},
|
|
2199
|
+
mockLogger
|
|
2200
|
+
)
|
|
2201
|
+
assert.deepStrictEqual([...result.rpc.wireAddonDeclarations.keys()], [])
|
|
2202
|
+
assert.deepStrictEqual([...result.rpc.usedAddons], [])
|
|
2178
2203
|
})
|
|
2179
2204
|
|
|
2180
2205
|
test('drops wired addons for a unit without the catch-all that references no namespace', () => {
|
|
@@ -1258,21 +1258,17 @@ export function filterInspectorState(
|
|
|
1258
1258
|
) as Array<{ tools?: string[] }>) {
|
|
1259
1259
|
for (const tool of agentMeta.tools ?? []) referencedIds.add(tool)
|
|
1260
1260
|
}
|
|
1261
|
-
//
|
|
1262
|
-
//
|
|
1263
|
-
//
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1261
|
+
// An addon RPC named outright in the filter is what a unit built FOR that
|
|
1262
|
+
// addon is given, so its declaration has to survive even though no
|
|
1263
|
+
// app-local function references it.
|
|
1264
|
+
for (const name of filters.names ?? []) {
|
|
1265
|
+
const namespace = name.slice(0, name.indexOf(':'))
|
|
1266
|
+
if (namespace && filteredState.rpc.wireAddonDeclarations.has(namespace)) {
|
|
1267
|
+
referencedIds.add(name)
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
1270
|
const keptNamespaces = new Set<string>()
|
|
1271
1271
|
for (const namespace of filteredState.rpc.wireAddonDeclarations.keys()) {
|
|
1272
|
-
if (hasRpcCatchAll) {
|
|
1273
|
-
keptNamespaces.add(namespace)
|
|
1274
|
-
continue
|
|
1275
|
-
}
|
|
1276
1272
|
const prefix = `${namespace}:`
|
|
1277
1273
|
for (const id of referencedIds) {
|
|
1278
1274
|
if (id.startsWith(prefix)) {
|