@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 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 '${name}' is missing a description.`);
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[name] = {
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
- // A unit carrying the generic `/rpc/:rpcName` catch-all (the rpcCaller
918
- // unit, and agent units) dispatches arbitrary RPCs at runtime via
919
- // `rpc.exposed(name)`, including namespaced addon RPCs (e.g.
920
- // `admin:createUser`). Those never appear in referencedIds because no
921
- // app-local function statically references them, so keep every wired
922
- // addon's declaration for such units — otherwise the per-unit bootstrap
923
- // omits the addon package bootstrap and `rpc.exposed('admin:createUser')`
924
- // throws RPCNotFoundError on the deployed stage.
925
- const hasRpcCatchAll = (filters.names ?? []).includes('/rpc/:rpcName');
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.73",
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.102"
48
+ "@pikku/core": "^0.12.107"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.102",
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
+ })
@@ -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 '${name}' is missing a description.`)
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[name] = {
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 all wired addons for a unit with the /rpc/:rpcName catch-all', () => {
2156
+ test('keeps only the addon whose function the unit is built for', () => {
2165
2157
  const result = filterInspectorState(
2166
2158
  withAddons(),
2167
- { names: ['/rpc/:rpcName', 'http:post:/rpc/:rpcName', 'rpcCaller'] },
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()].sort(),
2172
- ['admin', 'console']
2170
+ [...result.rpc.wireAddonDeclarations.keys()],
2171
+ ['console']
2173
2172
  )
2174
- assert.deepStrictEqual([...result.rpc.usedAddons].sort(), [
2175
- 'admin',
2176
- 'console',
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
- // A unit carrying the generic `/rpc/:rpcName` catch-all (the rpcCaller
1262
- // unit, and agent units) dispatches arbitrary RPCs at runtime via
1263
- // `rpc.exposed(name)`, including namespaced addon RPCs (e.g.
1264
- // `admin:createUser`). Those never appear in referencedIds because no
1265
- // app-local function statically references them, so keep every wired
1266
- // addon's declaration for such units — otherwise the per-unit bootstrap
1267
- // omits the addon package bootstrap and `rpc.exposed('admin:createUser')`
1268
- // throws RPCNotFoundError on the deployed stage.
1269
- const hasRpcCatchAll = (filters.names ?? []).includes('/rpc/:rpcName')
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)) {