Skip to main content
Glama

linkedin_publish

Publish original LinkedIn posts with a preview-first workflow. Dry run returns formatted text, resolved mentions, and media validation. After user confirms, call again with dry_run=false to publish.

Instructions

Publish an original post to LinkedIn via Unipile. IMPORTANT: dry_run defaults to true — this returns a preview showing the formatted text, resolved mentions, validated media, and character count. Review the preview carefully, then call again with dry_run=false to actually publish. Supports text (max 3000 chars), media attachments (local file paths or URLs to images/videos: jpg, png, gif, webp, mp4), and company @mentions (pass company names — they are resolved automatically via Unipile and injected as {{0}}, {{1}} placeholders). WORKFLOW: 1) Call with dry_run=true, 2) Present preview to user, 3) Get confirmation, 4) Call with dry_run=false.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
textYesPost body text. Maximum 3000 characters. Include company names here if you want them @mentioned — they will be replaced with Unipile placeholders automatically.
account_idNoOptional. Unipile account ID to post from. If omitted, the first LinkedIn account found in Unipile is used (default behavior).
mediaNoOptional. Array of local file paths or URLs to attach. Supported formats: jpg, png, gif, webp, mp4. URLs are downloaded to /tmp automatically.
mentionsNoOptional. Array of company names to @mention (e.g. ["Microsoft", "OpenAI"]). Each name is resolved to a LinkedIn company ID via Unipile.
dry_runNoDEFAULT TRUE. When true, returns a preview without publishing. Set to false only after user confirms the preview.

Implementation Reference

  • Main handler function for the linkedin_publish tool. Orchestrates the entire publish flow: normalizes arguments, resolves account/mentions, processes media, handles dry-run preview, and executes the actual publish via createPost().
    export async function handlePublish(args) {
    	const { text, account_id } = args;
    
    	// Normalize media: Claude Code may pass array as JSON string "[...]"
    	let media = args.media ?? [];
    	if (typeof media === "string") {
    		try {
    			media = JSON.parse(media);
    			if (!Array.isArray(media)) media = [media];
    		} catch {
    			media = media.length > 0 ? [media] : [];
    		}
    	}
    	if (!Array.isArray(media)) media = [];
    
    	// Normalize mentions similarly
    	let mentions = args.mentions ?? [];
    	if (typeof mentions === "string") {
    		try {
    			mentions = JSON.parse(mentions);
    			if (!Array.isArray(mentions)) mentions = [mentions];
    		} catch {
    			mentions = mentions.length > 0 ? [mentions] : [];
    		}
    	}
    	if (!Array.isArray(mentions)) mentions = [];
    
    	// Normalize dry_run: string "false" is truthy in JS — handle explicitly
    	const dry_run =
    		args.dry_run === false || args.dry_run === "false" ? false : true;
    
    	// ── Validate text ────────────────────────────────────────────────────────
    	if (!text || typeof text !== "string" || text.trim().length === 0) {
    		return { error: "text is required and must be a non-empty string" };
    	}
    
    	const warnings = [];
    
    	// ── Resolve account ──────────────────────────────────────────────────────
    	let accountId;
    	if (account_id) {
    		accountId = account_id;
    	} else {
    		const accountResult = await resolveAccountId();
    		if (!accountResult.success) {
    			return {
    				error: `Could not resolve LinkedIn account: ${accountResult.error}`,
    			};
    		}
    		accountId = accountResult.data;
    	}
    
    	// ── Resolve company mentions ─────────────────────────────────────────────
    	const resolvedMentions = [];
    	for (const companyName of mentions) {
    		const result = await resolveCompanyId(companyName);
    		if (result.success) {
    			resolvedMentions.push(result.data);
    		} else {
    			warnings.push(`Mention not resolved: "${companyName}" — ${result.error}`);
    		}
    	}
    
    	// ── Inject placeholders into text ────────────────────────────────────────
    	const finalText = injectMentionPlaceholders(text, resolvedMentions);
    
    	// ── Character count check ────────────────────────────────────────────────
    	if (finalText.length > CHAR_LIMIT) {
    		warnings.push(
    			`Post exceeds ${CHAR_LIMIT} character limit (${finalText.length} chars). LinkedIn will reject it.`,
    		);
    	}
    
    	// ── Process media ────────────────────────────────────────────────────────
    	let mediaResults = [];
    	let mediaFiles = [];
    	if (media.length > 0) {
    		const { resolved, failed } = await processMedia(media);
    		mediaFiles = resolved;
    
    		mediaResults = [
    			...resolved.map((m) => ({
    				source: m.source,
    				valid: true,
    				type: m.mimeType,
    				size_kb: Math.round(m.sizeBytes / 1024),
    			})),
    			...failed.map((f) => ({
    				source: f.source,
    				valid: false,
    				error: f.error,
    			})),
    		];
    
    		if (failed.length > 0) {
    			warnings.push(
    				`${failed.length} media item(s) could not be processed and will be skipped.`,
    			);
    		}
    	}
    
    	// ── Dry run — return preview ─────────────────────────────────────────────
    	if (dry_run) {
    		cleanupTmpMedia(); // Clean up downloads since we won't publish
    		return {
    			status: "preview",
    			post_text: finalText,
    			character_count: finalText.length,
    			character_limit: CHAR_LIMIT,
    			media: mediaResults,
    			mentions: mentions.map((name, i) => {
    				const resolved = resolvedMentions[i];
    				return resolved
    					? {
    							name: resolved.name,
    							resolved: true,
    							profile_id: resolved.profileId,
    						}
    					: { name, resolved: false };
    			}),
    			warnings,
    			ready_to_publish:
    				warnings.length === 0 || !warnings.some((w) => w.includes("exceeds")),
    		};
    	}
    
    	// ── Publish ──────────────────────────────────────────────────────────────
    	try {
    		const result = await createPost(
    			accountId,
    			finalText,
    			mediaFiles,
    			resolvedMentions,
    		);
    
    		if (!result.success) {
    			return { error: result.error, details: result.details };
    		}
    
    		const postId = result.data.postId;
    
    		let autoLike;
    		if (postId) {
    			const urn = `urn:li:activity:${postId}`;
    			const likeResult = await reactToPost(accountId, urn, "like");
    			autoLike = likeResult.success ? "liked" : `failed: ${likeResult.error}`;
    		} else {
    			autoLike = "skipped: no post_id returned";
    		}
    
    		return {
    			status: "published",
    			post_id: postId,
    			post_text: finalText,
    			posted_at: result.data.postedAt,
    			auto_like: autoLike,
    		};
    	} finally {
    		cleanupTmpMedia();
    	}
    }
  • Input schema for the linkedin_publish tool. Defines properties: text (required), account_id (optional), media (optional array of strings), mentions (optional array of strings), dry_run (optional boolean, defaults true).
    inputSchema: {
    	type: "object",
    	properties: {
    		text: {
    			type: "string",
    			description:
    				"Post body text. Maximum 3000 characters. Include company names here if you want them @mentioned — they will be replaced with Unipile placeholders automatically.",
    		},
    		account_id: {
    			type: "string",
    			description:
    				"Optional. Unipile account ID to post from. If omitted, the first LinkedIn account found in Unipile is used (default behavior).",
    		},
    		media: {
    			type: "array",
    			items: { type: "string" },
    			description:
    				"Optional. Array of local file paths or URLs to attach. Supported formats: jpg, png, gif, webp, mp4. URLs are downloaded to /tmp automatically.",
    			default: [],
    		},
    		mentions: {
    			type: "array",
    			items: { type: "string" },
    			description:
    				'Optional. Array of company names to @mention (e.g. ["Microsoft", "OpenAI"]). Each name is resolved to a LinkedIn company ID via Unipile.',
    			default: [],
    		},
    		dry_run: {
    			type: "boolean",
    			description:
    				"DEFAULT TRUE. When true, returns a preview without publishing. Set to false only after user confirms the preview.",
    			default: true,
    		},
    	},
    	required: ["text"],
    },
  • src/server.js:17-66 (registration)
    Tool definition registration for linkedin_publish. Includes name, description with workflow instructions, and inputSchema. Listed in the TOOLS array returned by ListToolsRequestSchema.
    const TOOLS = [
    	{
    		name: "linkedin_publish",
    		description:
    			"Publish an original post to LinkedIn via Unipile. " +
    			"IMPORTANT: dry_run defaults to true — this returns a preview showing the formatted text, " +
    			"resolved mentions, validated media, and character count. Review the preview carefully, " +
    			"then call again with dry_run=false to actually publish. " +
    			"Supports text (max 3000 chars), media attachments (local file paths or URLs to images/videos: " +
    			"jpg, png, gif, webp, mp4), and company @mentions (pass company names — they are resolved " +
    			"automatically via Unipile and injected as {{0}}, {{1}} placeholders). " +
    			"WORKFLOW: 1) Call with dry_run=true, 2) Present preview to user, 3) Get confirmation, " +
    			"4) Call with dry_run=false.",
    		inputSchema: {
    			type: "object",
    			properties: {
    				text: {
    					type: "string",
    					description:
    						"Post body text. Maximum 3000 characters. Include company names here if you want them @mentioned — they will be replaced with Unipile placeholders automatically.",
    				},
    				account_id: {
    					type: "string",
    					description:
    						"Optional. Unipile account ID to post from. If omitted, the first LinkedIn account found in Unipile is used (default behavior).",
    				},
    				media: {
    					type: "array",
    					items: { type: "string" },
    					description:
    						"Optional. Array of local file paths or URLs to attach. Supported formats: jpg, png, gif, webp, mp4. URLs are downloaded to /tmp automatically.",
    					default: [],
    				},
    				mentions: {
    					type: "array",
    					items: { type: "string" },
    					description:
    						'Optional. Array of company names to @mention (e.g. ["Microsoft", "OpenAI"]). Each name is resolved to a LinkedIn company ID via Unipile.',
    					default: [],
    				},
    				dry_run: {
    					type: "boolean",
    					description:
    						"DEFAULT TRUE. When true, returns a preview without publishing. Set to false only after user confirms the preview.",
    					default: true,
    				},
    			},
    			required: ["text"],
    		},
    	},
  • src/server.js:147-149 (registration)
    Tool call dispatch for linkedin_publish in the CallToolRequestSchema handler. Routes the 'linkedin_publish' case to handlePublish() from ./tools/publish.js.
    case "linkedin_publish":
    	result = await handlePublish(args || {});
    	break;
  • Helper function injectMentionPlaceholders - replaces company names in post text with {{0}}, {{1}} placeholders for Unipile mention injection.
    export function injectMentionPlaceholders(text, resolvedMentions) {
    	let result = text;
    	for (let i = 0; i < resolvedMentions.length; i++) {
    		const name = resolvedMentions[i].name;
    		const placeholder = `{{${i}}}`;
    		const nameRegex = new RegExp(
    			`(?<![\\w@])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w])`,
    			"gi",
    		);
    		if (nameRegex.test(result)) {
    			result = result.replace(nameRegex, placeholder);
    		} else {
    			result += ` ${placeholder}`;
    		}
    	}
    	return result;
    }
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations are provided, so the description must fully disclose behavior. It explains that dry_run returns a preview with formatted text, resolved mentions, validated media, and character count, and that actual publishing requires dry_run=false. It also describes media handling and mention resolution. However, it does not discuss idempotency, error handling, or authentication needs, which would make it complete.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is a single, well-structured paragraph that front-loads the purpose, then highlights the critical dry_run note, lists supported content, and provides a numbered workflow. Every sentence contributes necessary information without redundancy.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the complexity (5 parameters, 1 required, no output schema, no annotations), the description effectively covers all parameters and the two-phase workflow. It describes the preview content, but lacks details on the response after successful publish. It is sufficiently complete for an AI agent to use the tool correctly.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100%, providing baseline 3. The description adds value by explaining the dry_run workflow implications, that mentions are resolved to placeholders, that media URLs are downloaded to /tmp, and that account_id defaults to the first LinkedIn account. This goes beyond the schema descriptions.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states 'Publish an original post to LinkedIn via Unipile,' using a specific verb and resource. It distinguishes from sibling tools (linkedin_comment, linkedin_react) by focusing on original posts and the dry_run workflow.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description explicitly outlines when to use the tool, emphasizing the dry_run workflow with a step-by-step process: call with dry_run=true to preview, then with dry_run=false to publish. It provides clear guidance on not publishing without user confirmation.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/timkulbaev/mcp-linkedin'

If you have feedback or need assistance with the MCP directory API, please join our Discord server